diff --git a/apps/hash-api/src/graph/knowledge/primitive/entity.ts b/apps/hash-api/src/graph/knowledge/primitive/entity.ts index ad198117f36..0d27aeed7b6 100644 --- a/apps/hash-api/src/graph/knowledge/primitive/entity.ts +++ b/apps/hash-api/src/graph/knowledge/primitive/entity.ts @@ -19,6 +19,7 @@ import { HashLinkEntity, queryEntities, queryEntitySubgraph, + summarizeEntities, } from "@local/hash-graph-sdk/entity"; import { getActorGroupRole } from "@local/hash-graph-sdk/principal/actor-group"; import { @@ -58,7 +59,6 @@ import type { import type { Subtype } from "@local/advanced-types/subtype"; import type { AllFilter, - CountEntitiesParams, DiffEntityResult, Filter, HasPermissionForEntitiesParams, @@ -160,12 +160,6 @@ export const createEntity = async < return entity; }; -export const countEntities: ImpureGraphFunction< - CountEntitiesParams, - Promise -> = async ({ graphApi }, { actorId }, params) => - graphApi.countEntities(actorId, params).then(({ data }) => data); - type GetLatestEntityByIdFunction< Properties extends TypeIdsAndPropertiesForEntity = TypeIdsAndPropertiesForEntity, @@ -315,15 +309,18 @@ export const canUserReadEntity: ImpureGraphFunction< }); } - const count = await countEntities(context, authentication, { + const { count } = await summarizeEntities(context, authentication, { filter: { all: allFilter, }, temporalAxes: currentTimeInstantTemporalAxes, includeDrafts: !!draftId || includeDrafts, + includeCount: true, }); - if (count === 0) { + // Deny on a missing/zero count rather than failing open: a permission gate must not + // grant access when the count is absent (`count` is typed optional on the response). + if (!count) { throw new Error( `Entity with entityId ${entityId} doesn't exist or cannot be accessed by requesting user.`, ); diff --git a/apps/hash-api/src/graphql/resolvers/index.ts b/apps/hash-api/src/graphql/resolvers/index.ts index 1dbfa76322a..d29d549f16b 100644 --- a/apps/hash-api/src/graphql/resolvers/index.ts +++ b/apps/hash-api/src/graphql/resolvers/index.ts @@ -37,7 +37,7 @@ import { addEntityViewerResolver, archiveEntitiesResolver, archiveEntityResolver, - countEntitiesResolver, + summarizeEntitiesResolver, createEntityResolver, isEntityPublicResolver, queryEntitiesResolver, @@ -157,7 +157,7 @@ export const resolvers: Omit & { "`getEntityAuthorizationRelationships` is not implemented", ); }), - countEntities: loggedInAndSignedUpMiddleware(countEntitiesResolver), + summarizeEntities: loggedInAndSignedUpMiddleware(summarizeEntitiesResolver), queryEntities: loggedInAndSignedUpMiddleware(queryEntitiesResolver), queryEntitySubgraph: loggedInAndSignedUpMiddleware( queryEntitySubgraphResolver, diff --git a/apps/hash-api/src/graphql/resolvers/knowledge/entity/entity.ts b/apps/hash-api/src/graphql/resolvers/knowledge/entity/entity.ts index a9c43736b1b..6c9b1ba06e5 100644 --- a/apps/hash-api/src/graphql/resolvers/knowledge/entity/entity.ts +++ b/apps/hash-api/src/graphql/resolvers/knowledge/entity/entity.ts @@ -9,6 +9,7 @@ import { queryEntitySubgraph, serializeQueryEntitiesResponse, serializeQueryEntitySubgraphResponse, + summarizeEntities, } from "@local/hash-graph-sdk/entity"; import { createPolicy, @@ -19,7 +20,6 @@ import { import { canUserReadEntity, checkEntityPermission, - countEntities, createEntityWithLinks, getLatestEntityById, updateEntity, @@ -42,7 +42,7 @@ import type { MutationUpdateEntitiesArgs, MutationUpdateEntityArgs, Query, - QueryCountEntitiesArgs, + QuerySummarizeEntitiesArgs, QueryIsEntityPublicArgs, QueryQueryEntitiesArgs, QueryQueryEntitySubgraphArgs, @@ -105,13 +105,13 @@ export const createEntityResolver: ResolverFn< return entity; }; -export const countEntitiesResolver: ResolverFn< - Query["countEntities"], +export const summarizeEntitiesResolver: ResolverFn< + Query["summarizeEntities"], Record, GraphQLContext, - QueryCountEntitiesArgs + QuerySummarizeEntitiesArgs > = async (_, { request }, graphQLContext) => - countEntities( + summarizeEntities( graphQLContextToImpureGraphContext(graphQLContext), graphQLContext.authentication, request, diff --git a/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts b/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts index cadd76cd031..47458ba0850 100644 --- a/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts +++ b/apps/hash-frontend/src/graphql/queries/knowledge/entity.queries.ts @@ -31,9 +31,9 @@ export const queryEntitySubgraphQuery = gql` } `; -export const countEntitiesQuery = gql` - query countEntities($request: CountEntitiesParams!) { - countEntities(request: $request) +export const summarizeEntitiesQuery = gql` + query summarizeEntities($request: SummarizeEntitiesParams!) { + summarizeEntities(request: $request) } `; diff --git a/apps/hash-frontend/src/pages/index.page/waitlisted.tsx b/apps/hash-frontend/src/pages/index.page/waitlisted.tsx index 73d0982dcb0..038fbf3b7bd 100644 --- a/apps/hash-frontend/src/pages/index.page/waitlisted.tsx +++ b/apps/hash-frontend/src/pages/index.page/waitlisted.tsx @@ -15,7 +15,7 @@ import { import { isSelfHostedInstance } from "@local/hash-isomorphic-utils/instance"; import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; -import { countEntitiesQuery } from "../../graphql/queries/knowledge/entity.queries"; +import { summarizeEntitiesQuery } from "../../graphql/queries/knowledge/entity.queries"; import { getWaitlistPositionQuery, submitEarlyAccessFormMutation, @@ -30,8 +30,8 @@ import { UsesCard } from "./shared/uses-card"; import { EarlyAccessFormModal } from "./waitlisted/early-access-modal"; import type { - CountEntitiesQuery, - CountEntitiesQueryVariables, + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables, GetWaitlistPositionQuery, SubmitEarlyAccessFormMutation, SubmitEarlyAccessFormMutationVariables, @@ -65,8 +65,8 @@ export const Waitlisted = () => { "closed" | "open" | "submitted" >("closed"); - useQuery( - countEntitiesQuery, + useQuery( + summarizeEntitiesQuery, { variables: { request: { @@ -75,11 +75,12 @@ export const Waitlisted = () => { ), includeDrafts: false, temporalAxes: currentTimeInstantTemporalAxes, + includeCount: true, }, }, fetchPolicy: "cache-and-network", onCompleted: (data) => { - if (data.countEntities > 0) { + if ((data.summarizeEntities.count ?? 0) > 0) { setEarlyAccessFormState("submitted"); } }, diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-available-types.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-available-types.ts index a2033935e82..fc097b891b4 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-available-types.ts +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-available-types.ts @@ -4,7 +4,7 @@ import { useMemo } from "react"; import { extractBaseUrl } from "@blockprotocol/type-system"; import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; -import { queryEntitySubgraphQuery } from "../../../../graphql/queries/knowledge/entity.queries"; +import { summarizeEntitiesQuery } from "../../../../graphql/queries/knowledge/entity.queries"; import { useEntityTypesContextRequired } from "../../../../shared/entity-types-context/hooks/use-entity-types-context-required"; import { usePropertyTypes } from "../../../../shared/property-types-context"; import { useDataTypesContext } from "../../data-types-context"; @@ -12,8 +12,8 @@ import { buildEntitiesFilter } from "./build-filter"; import { deriveFilterableProperties } from "./property-filters/derive-filterable-properties"; import type { - QueryEntitySubgraphQuery, - QueryEntitySubgraphQueryVariables, + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables, } from "../../../../graphql/api-types.gen"; import type { EntitiesFilterState } from "./filter-state"; import type { FilterMetadataForProperty } from "./property-filters/property-filter"; @@ -78,21 +78,18 @@ export const useAvailableTypes = ({ ); const { data, loading } = useQuery< - QueryEntitySubgraphQuery, - QueryEntitySubgraphQueryVariables - >(queryEntitySubgraphQuery, { + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables + >(summarizeEntitiesQuery, { skip: !shouldFetchAvailableTypes, fetchPolicy: "cache-and-network", variables: { request: { - limit: 1, filter, includeTypeIds: true, includeTypeTitles: true, temporalAxes: currentTimeInstantTemporalAxes, includeDrafts: false, - includePermissions: false, - traversalPaths: [], }, }, }); @@ -105,8 +102,8 @@ export const useAvailableTypes = ({ return { availableEntityTypes: [], propertyFilterData: [] }; } - const typeIds = data?.queryEntitySubgraph.typeIds ?? {}; - const typeTitles = data?.queryEntitySubgraph.typeTitles ?? {}; + const typeIds = data?.summarizeEntities.typeIds ?? {}; + const typeTitles = data?.summarizeEntities.typeTitles ?? {}; const availableTypes = Object.entries(typeIds) .map(([entityTypeId, count]) => { diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx index 96caf246acc..af55f4d430e 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx @@ -10,8 +10,8 @@ import { import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; import { - countEntitiesQuery, queryEntitySubgraphQuery, + summarizeEntitiesQuery, } from "../../../graphql/queries/knowledge/entity.queries"; import { apolloClient } from "../../../lib/apollo-client"; import { buildEntitiesFilter } from "./shared/build-filter"; @@ -19,10 +19,10 @@ import { traversalPathsForView } from "./shared/traversal-paths"; import { useEntitiesTableData } from "./use-entities-table-data"; import type { - CountEntitiesQuery, - CountEntitiesQueryVariables, QueryEntitySubgraphQuery, QueryEntitySubgraphQueryVariables, + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables, } from "../../../graphql/api-types.gen"; import type { VisualizerView } from "../visualizer-views"; import type { @@ -128,15 +128,16 @@ export const useEntitiesVisualizerData = (params: { [conversions, cursor, filter, limit, sort, view], ); - const { data: countData } = useQuery< - CountEntitiesQuery, - CountEntitiesQueryVariables - >(countEntitiesQuery, { + const { data: summaryData } = useQuery< + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables + >(summarizeEntitiesQuery, { variables: { request: { filter, temporalAxes: currentTimeInstantTemporalAxes, includeDrafts: false, + includeCount: true, }, }, }); @@ -203,12 +204,12 @@ export const useEntitiesVisualizerData = (params: { refetch, subgraph, tableData, - totalResultCount: countData?.countEntities ?? null, + totalResultCount: summaryData?.summarizeEntities.count ?? null, updateTableData, }), [ data?.queryEntitySubgraph, - countData?.countEntities, + summaryData?.summarizeEntities, entities, hadCachedContent, loading, diff --git a/apps/hash-frontend/src/shared/draft-entities-count-context.tsx b/apps/hash-frontend/src/shared/draft-entities-count-context.tsx index 1ee5a6ee73c..da8b2131603 100644 --- a/apps/hash-frontend/src/shared/draft-entities-count-context.tsx +++ b/apps/hash-frontend/src/shared/draft-entities-count-context.tsx @@ -3,13 +3,13 @@ import { createContext, useContext, useMemo } from "react"; import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; -import { countEntitiesQuery } from "../graphql/queries/knowledge/entity.queries"; +import { summarizeEntitiesQuery } from "../graphql/queries/knowledge/entity.queries"; import { useAuthInfo } from "../pages/shared/auth-info-context"; import { usePollInterval } from "./use-poll-interval"; import type { - CountEntitiesQuery, - CountEntitiesQueryVariables, + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables, } from "../graphql/api-types.gen"; import type { FunctionComponent, PropsWithChildren } from "react"; @@ -43,8 +43,8 @@ export const DraftEntitiesCountContextProvider: FunctionComponent< data: draftEntitiesData, refetch, loading, - } = useQuery( - countEntitiesQuery, + } = useQuery( + summarizeEntitiesQuery, { variables: { request: { @@ -62,6 +62,7 @@ export const DraftEntitiesCountContextProvider: FunctionComponent< }, temporalAxes: currentTimeInstantTemporalAxes, includeDrafts: true, + includeCount: true, }, }, pollInterval, @@ -72,7 +73,7 @@ export const DraftEntitiesCountContextProvider: FunctionComponent< const value = useMemo( () => ({ - count: draftEntitiesData?.countEntities ?? undefined, + count: draftEntitiesData?.summarizeEntities.count ?? undefined, loading, refetch: async () => { await refetch(); diff --git a/apps/hash-frontend/src/shared/generate-sidebar-entities-query-variables.tsx b/apps/hash-frontend/src/shared/generate-sidebar-entities-query-variables.tsx index 79cd3879cd3..10409f0efe3 100644 --- a/apps/hash-frontend/src/shared/generate-sidebar-entities-query-variables.tsx +++ b/apps/hash-frontend/src/shared/generate-sidebar-entities-query-variables.tsx @@ -3,7 +3,7 @@ import { ignoreNoisySystemTypesFilter, } from "@local/hash-isomorphic-utils/graph-queries"; -import type { QueryEntitiesQueryVariables } from "../graphql/api-types.gen"; +import type { SummarizeEntitiesQueryVariables } from "../graphql/api-types.gen"; import type { WebId } from "@blockprotocol/type-system"; /** @@ -14,17 +14,13 @@ export const generateSidebarEntitiesQueryVariables = ({ webId, }: { webId: WebId; -}): QueryEntitiesQueryVariables => { +}): SummarizeEntitiesQueryVariables => { return { request: { /** - * We only make this request to get the count of entities by typeId to filter types in the sidebar, - * to only those for which the active workspace has at least one entity. - * - * We don't actually need a single entity but the Graph rejects requests with a limit of 0. - * We currently can't use countEntities as it just returns a total number, with no count by typeId. + * We only request the per-typeId counts, to filter the sidebar types to those for + * which the active workspace has at least one entity. */ - limit: 1, includeTypeIds: true, filter: { all: [ @@ -39,7 +35,6 @@ export const generateSidebarEntitiesQueryVariables = ({ }, temporalAxes: currentTimeInstantTemporalAxes, includeDrafts: false, - includePermissions: false, }, }; }; diff --git a/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/account-entities-list.tsx b/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/account-entities-list.tsx index 69952fceec2..77905a9213e 100644 --- a/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/account-entities-list.tsx +++ b/apps/hash-frontend/src/shared/layout/layout-with-sidebar/sidebar/account-entities-list.tsx @@ -15,7 +15,7 @@ import { blockProtocolHubOrigin } from "@local/hash-isomorphic-utils/blocks-cons import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import { useUpdateAuthenticatedUser } from "../../../../components/hooks/use-update-authenticated-user"; -import { queryEntitiesQuery } from "../../../../graphql/queries/knowledge/entity.queries"; +import { summarizeEntitiesQuery } from "../../../../graphql/queries/knowledge/entity.queries"; import { hiddenEntityTypeIds } from "../../../../pages/shared/hidden-types"; import { useActiveWorkspace } from "../../../../pages/shared/workspace-context"; import { useLatestEntityTypesOptional } from "../../../entity-types-context/hooks"; @@ -32,8 +32,8 @@ import { SortActionsDropdown } from "./shared/sort-actions-dropdown"; import { ViewAllLink } from "./shared/view-all-link"; import type { - QueryEntitiesQuery, - QueryEntitiesQueryVariables, + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables, } from "../../../../graphql/api-types.gen"; import type { SortType } from "./shared/sort-actions-dropdown"; import type { FunctionComponent } from "react"; @@ -85,9 +85,9 @@ export const AccountEntitiesList: FunctionComponent< } = useLatestEntityTypesOptional(); const { data: userEntitiesData, loading: userEntitiesLoading } = useQuery< - QueryEntitiesQuery, - QueryEntitiesQueryVariables - >(queryEntitiesQuery, { + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables + >(summarizeEntitiesQuery, { variables: generateSidebarEntitiesQueryVariables({ webId, }), @@ -112,9 +112,9 @@ export const AccountEntitiesList: FunctionComponent< (root) => ((isOwnedOntologyElementMetadata(root.metadata) && root.metadata.webId === webId) || - Object.keys(userEntitiesData?.queryEntities.typeIds ?? {}).includes( - root.schema.$id, - )) && + Object.keys( + userEntitiesData?.summarizeEntities.typeIds ?? {}, + ).includes(root.schema.$id)) && // Filter out external types from blockprotocol.org, except the Address type. (!root.schema.$id.startsWith(blockProtocolHubOrigin) || root.schema.$id.includes("/address/")) && diff --git a/apps/hash-frontend/src/shared/notification-count-context.tsx b/apps/hash-frontend/src/shared/notification-count-context.tsx index 62b5d1dbf28..357e4bfc5f9 100644 --- a/apps/hash-frontend/src/shared/notification-count-context.tsx +++ b/apps/hash-frontend/src/shared/notification-count-context.tsx @@ -12,7 +12,7 @@ import { queryEntitiesQuery } from "@local/hash-isomorphic-utils/graphql/queries import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import { - countEntitiesQuery, + summarizeEntitiesQuery, updateEntitiesMutation, updateEntityMutation, } from "../graphql/queries/knowledge/entity.queries"; @@ -20,8 +20,8 @@ import { useAuthInfo } from "../pages/shared/auth-info-context"; import { usePollInterval } from "./use-poll-interval"; import type { - CountEntitiesQuery, - CountEntitiesQueryVariables, + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables, QueryEntitiesQuery, QueryEntitiesQueryVariables, UpdateEntitiesMutation, @@ -87,11 +87,11 @@ export const NotificationCountContextProvider: FunctionComponent< const pollInterval = usePollInterval(); const { - data: notificationCountData, - loading: loadingNotificationCount, - refetch: refetchNotificationCount, - } = useQuery( - countEntitiesQuery, + data: notificationSummarizeData, + loading: loadingNotificationSummary, + refetch: refetchNotificationSummary, + } = useQuery( + summarizeEntitiesQuery, { pollInterval, variables: { @@ -113,6 +113,7 @@ export const NotificationCountContextProvider: FunctionComponent< }, temporalAxes: currentTimeInstantTemporalAxes, includeDrafts: false, + includeCount: true, }, }, skip: !authenticatedUser?.accountSignupComplete, @@ -131,14 +132,14 @@ export const NotificationCountContextProvider: FunctionComponent< UpdateEntityMutation, UpdateEntityMutationVariables >(updateEntityMutation, { - onCompleted: () => refetchNotificationCount(), + onCompleted: () => refetchNotificationSummary(), }); const [updateEntities] = useMutation< UpdateEntitiesMutation, UpdateEntitiesMutationVariables >(updateEntitiesMutation, { - onCompleted: () => refetchNotificationCount(), + onCompleted: () => refetchNotificationSummary(), }); const getNotificationsLinkingToEntity = useCallback( @@ -301,18 +302,18 @@ export const NotificationCountContextProvider: FunctionComponent< const value = useMemo( () => ({ archiveNotificationsForEntity, - loading: loadingNotificationCount, + loading: loadingNotificationSummary, markNotificationAsRead, markNotificationsAsReadForEntity, numberOfUnreadNotifications: - notificationCountData?.countEntities ?? undefined, + notificationSummarizeData?.summarizeEntities.count ?? undefined, }), [ archiveNotificationsForEntity, - loadingNotificationCount, + loadingNotificationSummary, markNotificationAsRead, markNotificationsAsReadForEntity, - notificationCountData, + notificationSummarizeData, ], ); diff --git a/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs b/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs index 3b9001842e7..098d5a0fb25 100644 --- a/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs @@ -200,9 +200,16 @@ impl ToSchema<'_> for BaseUrl { "BaseUrl", openapi::schema::ObjectBuilder::new() .schema_type(openapi::SchemaType::String) + .title(Some("Base URL")) + .description(Some( + "The base URL of a Block Protocol ontology type (the $id of the schema, \ + without the versioned suffix). It should be a valid URL, with a trailing \ + slash.", + )) .format(Some(openapi::SchemaFormat::KnownFormat( openapi::KnownFormat::Uri, ))) + .max_length(Some(2048)) .into(), ) } @@ -652,9 +659,15 @@ impl ToSchema<'_> for VersionedUrl { "VersionedUrl", openapi::schema::ObjectBuilder::new() .schema_type(openapi::SchemaType::String) + .title(Some("Versioned URL")) + .description(Some( + "The versioned URL of a Block Protocol ontology type (the $id of the schema). \ + It should be of the form `${baseUrl}v/${versionNumber}`", + )) .format(Some(openapi::SchemaFormat::KnownFormat( openapi::KnownFormat::Uri, ))) + .max_length(Some(2048)) .into(), ) } diff --git a/libs/@local/graph/api/openapi/models/shared.json b/libs/@local/graph/api/openapi/models/shared.json index b4afb0d5738..6386a3cb249 100644 --- a/libs/@local/graph/api/openapi/models/shared.json +++ b/libs/@local/graph/api/openapi/models/shared.json @@ -2,7 +2,7 @@ "definitions": { "BaseUrl": { "title": "Base URL", - "description": "The base URL of a Block Protocol ontology type (the $id of the schema, without the versioned suffix). It should a valid URL, with a trailing slash.", + "description": "The base URL of a Block Protocol ontology type (the $id of the schema, without the versioned suffix). It should be a valid URL, with a trailing slash.", "type": "string", "format": "uri", "maxLength": 2048 diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 3d4c93a04f7..1437bc886d6 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -1710,13 +1710,13 @@ } } }, - "/entities/query/count": { + "/entities/query/subgraph": { "post": { "tags": [ "Graph", "Entity" ], - "operationId": "count_entities", + "operationId": "query_entity_subgraph", "parameters": [ { "name": "X-Authenticated-User-Actor-Id", @@ -1726,13 +1726,44 @@ "schema": { "$ref": "#/components/schemas/ActorEntityUuid" } + }, + { + "name": "Interactive", + "in": "header", + "description": "Whether the query is interactive", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + }, + { + "name": "after", + "in": "query", + "description": "The cursor to start reading from", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "limit", + "in": "query", + "description": "The maximum number of entities to read", + "required": false, + "schema": { + "type": "integer", + "nullable": true, + "minimum": 0 + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CountEntitiesParams" + "$ref": "#/components/schemas/QueryEntitySubgraphRequest" } } }, @@ -1740,12 +1771,11 @@ }, "responses": { "200": { - "description": "", + "description": "A subgraph rooted at entities that satisfy the given query, each resolved to the requested depth.", "content": { "application/json": { "schema": { - "type": "integer", - "minimum": 0 + "$ref": "#/components/schemas/QueryEntitySubgraphResponse" } } } @@ -1759,13 +1789,13 @@ } } }, - "/entities/query/subgraph": { + "/entities/query/summarize": { "post": { "tags": [ "Graph", "Entity" ], - "operationId": "query_entity_subgraph", + "operationId": "summarize_entities", "parameters": [ { "name": "X-Authenticated-User-Actor-Id", @@ -1775,44 +1805,13 @@ "schema": { "$ref": "#/components/schemas/ActorEntityUuid" } - }, - { - "name": "Interactive", - "in": "header", - "description": "Whether the query is interactive", - "required": false, - "schema": { - "type": "boolean", - "nullable": true - } - }, - { - "name": "after", - "in": "query", - "description": "The cursor to start reading from", - "required": false, - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "limit", - "in": "query", - "description": "The maximum number of entities to read", - "required": false, - "schema": { - "type": "integer", - "nullable": true, - "minimum": 0 - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueryEntitySubgraphRequest" + "$ref": "#/components/schemas/SummarizeEntitiesParams" } } }, @@ -1820,11 +1819,11 @@ }, "responses": { "200": { - "description": "A subgraph rooted at entities that satisfy the given query, each resolved to the requested depth.", + "description": "", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueryEntitySubgraphResponse" + "$ref": "#/components/schemas/SummarizeEntitiesResponse" } } } @@ -3580,7 +3579,10 @@ }, "BaseUrl": { "type": "string", - "format": "uri" + "title": "Base URL", + "format": "uri", + "description": "The base URL of a Block Protocol ontology type (the $id of the schema, without the versioned suffix). It should be a valid URL, with a trailing slash.", + "maxLength": 2048 }, "ClosedDataType": { "$ref": "./models/closed_data_type.json" @@ -3767,26 +3769,6 @@ }, "additionalProperties": false }, - "CountEntitiesParams": { - "type": "object", - "required": [ - "filter", - "temporalAxes", - "includeDrafts" - ], - "properties": { - "filter": { - "$ref": "#/components/schemas/Filter" - }, - "includeDrafts": { - "type": "boolean" - }, - "temporalAxes": { - "$ref": "#/components/schemas/QueryTemporalAxesUnresolved" - } - }, - "additionalProperties": false - }, "CreateAiActorParams": { "type": "object", "required": [ @@ -4622,18 +4604,9 @@ ], "nullable": true }, - "includeCount": { - "type": "boolean" - }, - "includeCreatedByIds": { - "type": "boolean" - }, "includeDrafts": { "type": "boolean" }, - "includeEditionCreatedByIds": { - "type": "boolean" - }, "includeEntityTypes": { "allOf": [ { @@ -4645,15 +4618,6 @@ "includePermissions": { "type": "boolean" }, - "includeTypeIds": { - "type": "boolean" - }, - "includeTypeTitles": { - "type": "boolean" - }, - "includeWebIds": { - "type": "boolean" - }, "limit": { "type": "integer", "nullable": true, @@ -8040,17 +8004,6 @@ "$ref": "#/components/schemas/ClosedMultiEntityTypeMap" } }, - "count": { - "type": "integer", - "minimum": 0 - }, - "createdByIds": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 0 - } - }, "cursor": { "allOf": [ { @@ -8066,13 +8019,6 @@ } ] }, - "editionCreatedByIds": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 0 - } - }, "entities": { "type": "array", "items": { @@ -8084,26 +8030,6 @@ "additionalProperties": { "$ref": "#/components/schemas/EntityPermissions" } - }, - "typeIds": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 0 - } - }, - "typeTitles": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "webIds": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 0 - } } } }, @@ -8227,17 +8153,6 @@ "$ref": "#/components/schemas/ClosedMultiEntityTypeMap" } }, - "count": { - "type": "integer", - "minimum": 0 - }, - "createdByIds": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 0 - } - }, "cursor": { "allOf": [ { @@ -8253,13 +8168,6 @@ } ] }, - "editionCreatedByIds": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 0 - } - }, "entityPermissions": { "type": "object", "additionalProperties": { @@ -8268,26 +8176,6 @@ }, "subgraph": { "$ref": "#/components/schemas/Subgraph" - }, - "typeIds": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 0 - } - }, - "typeTitles": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "webIds": { - "type": "object", - "additionalProperties": { - "type": "integer", - "minimum": 0 - } } } }, @@ -8915,6 +8803,87 @@ } } }, + "SummarizeEntitiesParams": { + "type": "object", + "required": [ + "filter", + "temporalAxes", + "includeDrafts" + ], + "properties": { + "filter": { + "$ref": "#/components/schemas/Filter" + }, + "includeCount": { + "type": "boolean" + }, + "includeCreatedByIds": { + "type": "boolean" + }, + "includeDrafts": { + "type": "boolean" + }, + "includeEditionCreatedByIds": { + "type": "boolean" + }, + "includeTypeIds": { + "type": "boolean" + }, + "includeTypeTitles": { + "type": "boolean" + }, + "includeWebIds": { + "type": "boolean" + }, + "temporalAxes": { + "$ref": "#/components/schemas/QueryTemporalAxesUnresolved" + } + }, + "additionalProperties": false + }, + "SummarizeEntitiesResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "createdByIds": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "editionCreatedByIds": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "typeIds": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "typeTitles": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "webIds": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + } + } + }, "TeamId": { "$ref": "#/components/schemas/ActorGroupEntityUuid" }, @@ -9615,7 +9584,10 @@ }, "VersionedUrl": { "type": "string", - "format": "uri" + "title": "Versioned URL", + "format": "uri", + "description": "The versioned URL of a Block Protocol ontology type (the $id of the schema). It should be of the form `${baseUrl}v/${versionNumber}`", + "maxLength": 2048 }, "Vertex": { "oneOf": [ diff --git a/libs/@local/graph/api/src/rest/entity.rs b/libs/@local/graph/api/src/rest/entity.rs index 28b88f015db..01706aa25b9 100644 --- a/libs/@local/graph/api/src/rest/entity.rs +++ b/libs/@local/graph/api/src/rest/entity.rs @@ -10,15 +10,15 @@ use hash_graph_postgres_store::store::error::{EntityDoesNotExist, RaceConditionO use hash_graph_store::{ self, entity::{ - ClosedMultiEntityTypeMap, CountEntitiesParams, CreateEntityParams, DiffEntityParams, - DiffEntityResult, EntityPermissions, EntityQueryCursor, EntityQuerySortingRecord, - EntityQuerySortingToken, EntityQueryToken, EntityStore, EntityTypesError, - EntityValidationReport, EntityValidationType, HasPermissionForEntitiesParams, - LinkDataStateError, LinkDataValidationReport, LinkError, LinkTargetError, - LinkValidationReport, LinkedEntityError, MetadataValidationReport, PatchEntityParams, + ClosedMultiEntityTypeMap, CreateEntityParams, DiffEntityParams, DiffEntityResult, + EntityPermissions, EntityQueryCursor, EntityQuerySortingRecord, EntityQuerySortingToken, + EntityQueryToken, EntityStore, EntityTypesError, EntityValidationReport, + EntityValidationType, HasPermissionForEntitiesParams, LinkDataStateError, + LinkDataValidationReport, LinkError, LinkTargetError, LinkValidationReport, + LinkedEntityError, MetadataValidationReport, PatchEntityParams, PropertyMetadataValidationReport, QueryConversion, QueryEntitiesResponse, - UnexpectedEntityType, UpdateEntityEmbeddingsParams, ValidateEntityComponents, - ValidateEntityParams, + SummarizeEntitiesParams, SummarizeEntitiesResponse, UnexpectedEntityType, + UpdateEntityEmbeddingsParams, ValidateEntityComponents, ValidateEntityParams, }, entity_type::EntityTypeResolveDefinitions, pool::StorePool, @@ -67,10 +67,7 @@ use type_system::{ value::{ValueMetadata, metadata::ValueProvenance}, }, ontology::VersionedUrl, - principal::{ - actor::{ActorEntityUuid, ActorType}, - actor_group::WebId, - }, + principal::actor::ActorType, provenance::{Location, OriginProvenance, SourceProvenance, SourceType}, }; use utoipa::{OpenApi, ToSchema}; @@ -95,7 +92,7 @@ use crate::rest::{ has_permission_for_entities, query_entities, query_entity_subgraph, - count_entities, + summarize_entities, patch_entity, update_entity_embeddings, diff_entity, @@ -108,7 +105,8 @@ use crate::rest::{ PropertyArrayWithMetadata, PropertyObjectWithMetadata, ValidateEntityParams, - CountEntitiesParams, + SummarizeEntitiesParams, + SummarizeEntitiesResponse, EntityValidationType, ValidateEntityComponents, Embedding, @@ -231,7 +229,7 @@ impl EntityResource { Router::new() .route("/", post(query_entities::)) .route("/subgraph", post(query_entity_subgraph::)) - .route("/count", post(count_entities::)), + .route("/summarize", post(summarize_entities::)), ), ) } @@ -490,30 +488,12 @@ struct QueryEntitySubgraphResponse<'r> { cursor: Option>, #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] - count: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] closed_multi_entity_types: Option>, #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] definitions: Option, #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] - web_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - created_by_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - edition_created_by_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - type_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - type_titles: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] entity_permissions: Option>, } @@ -590,14 +570,8 @@ where Json(QueryEntitySubgraphResponse { subgraph: response.subgraph.into(), cursor: response.cursor.map(EntityQueryCursor::into_owned), - count: response.count, closed_multi_entity_types: response.closed_multi_entity_types, definitions: response.definitions, - web_ids: response.web_ids, - created_by_ids: response.created_by_ids, - edition_created_by_ids: response.edition_created_by_ids, - type_ids: response.type_ids, - type_titles: response.type_titles, entity_permissions: response.entity_permissions, }) }) @@ -610,8 +584,8 @@ where #[utoipa::path( post, - path = "/entities/query/count", - request_body = CountEntitiesParams, + path = "/entities/query/summarize", + request_body = SummarizeEntitiesParams, tag = "Entity", params( ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), @@ -621,24 +595,24 @@ where ( status = 200, content_type = "application/json", - body = usize, + body = SummarizeEntitiesResponse, ), (status = 422, content_type = "text/plain", description = "Provided query is invalid"), (status = 500, description = "Store error occurred"), ) )] -async fn count_entities( +async fn summarize_entities( AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, store_pool: Extension>, temporal_client: Extension>>, mut query_logger: Option>, Json(request): Json, -) -> Result, BoxedResponse> +) -> Result, BoxedResponse> where S: StorePool + Send + Sync, { if let Some(query_logger) = &mut query_logger { - query_logger.capture(actor_id, OpenApiQuery::CountEntities(&request)); + query_logger.capture(actor_id, OpenApiQuery::SummarizeEntities(&request)); } let store = store_pool @@ -647,9 +621,9 @@ where .map_err(report_to_response)?; let response = store - .count_entities( + .summarize_entities( actor_id, - CountEntitiesParams::deserialize(&request) + SummarizeEntitiesParams::deserialize(&request) .map_err(Report::from) .map_err(report_to_response)?, ) diff --git a/libs/@local/graph/api/src/rest/entity_query_request.rs b/libs/@local/graph/api/src/rest/entity_query_request.rs index 4d96c3bdb4b..3c0d3c21143 100644 --- a/libs/@local/graph/api/src/rest/entity_query_request.rs +++ b/libs/@local/graph/api/src/rest/entity_query_request.rs @@ -156,10 +156,6 @@ fn generate_sorting_paths( /// /// See and for more details. #[derive(Debug, Clone, Deserialize)] -#[expect( - clippy::struct_excessive_bools, - reason = "Parameter struct deserialized from JSON" -)] #[serde(rename_all = "camelCase")] struct FlatQueryEntitiesRequestData<'q, 's, 'p> { // `QueryEntitiesQuery::Filter` @@ -180,19 +176,7 @@ struct FlatQueryEntitiesRequestData<'q, 's, 'p> { #[serde(borrow)] cursor: Option>, #[serde(default)] - include_count: bool, - #[serde(default)] include_entity_types: Option, - #[serde(default)] - include_web_ids: bool, - #[serde(default)] - include_created_by_ids: bool, - #[serde(default)] - include_edition_created_by_ids: bool, - #[serde(default)] - include_type_ids: bool, - #[serde(default)] - include_type_titles: bool, include_permissions: bool, traversal_paths: Option>, @@ -478,10 +462,6 @@ impl core::error::Error for EntityQueryOptionsError {} #[derive(Debug, Clone, Deserialize, ToSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -#[expect( - clippy::struct_excessive_bools, - reason = "Parameter struct deserialized from JSON" -)] pub struct EntityQueryOptions<'s, 'p> { pub temporal_axes: QueryTemporalAxesUnresolved, pub include_drafts: bool, @@ -493,19 +473,7 @@ pub struct EntityQueryOptions<'s, 'p> { #[serde(borrow)] pub cursor: Option>, #[serde(default)] - pub include_count: bool, - #[serde(default)] pub include_entity_types: Option, - #[serde(default)] - pub include_web_ids: bool, - #[serde(default)] - pub include_created_by_ids: bool, - #[serde(default)] - pub include_edition_created_by_ids: bool, - #[serde(default)] - pub include_type_ids: bool, - #[serde(default)] - pub include_type_titles: bool, pub include_permissions: bool, } @@ -522,13 +490,7 @@ impl<'q, 's, 'p> TryFrom> for EntityQue conversions, sorting_paths, cursor, - include_count, include_entity_types, - include_web_ids, - include_created_by_ids, - include_edition_created_by_ids, - include_type_ids, - include_type_titles, include_permissions, graph_resolve_depths, traversal_paths, @@ -561,13 +523,7 @@ impl<'q, 's, 'p> TryFrom> for EntityQue conversions, sorting_paths, cursor, - include_count, include_entity_types, - include_web_ids, - include_created_by_ids, - include_edition_created_by_ids, - include_type_ids, - include_type_titles, include_permissions, }) } @@ -597,14 +553,8 @@ impl<'p> EntityQueryOptions<'_, 'p> { limit, conversions: self.conversions, include_drafts: self.include_drafts, - include_count: self.include_count, include_entity_types: self.include_entity_types, temporal_axes: self.temporal_axes, - include_web_ids: self.include_web_ids, - include_created_by_ids: self.include_created_by_ids, - include_edition_created_by_ids: self.include_edition_created_by_ids, - include_type_ids: self.include_type_ids, - include_type_titles: self.include_type_titles, include_permissions: self.include_permissions, }) } diff --git a/libs/@local/graph/api/src/rest/json_schemas/shared.json b/libs/@local/graph/api/src/rest/json_schemas/shared.json index b4afb0d5738..6386a3cb249 100644 --- a/libs/@local/graph/api/src/rest/json_schemas/shared.json +++ b/libs/@local/graph/api/src/rest/json_schemas/shared.json @@ -2,7 +2,7 @@ "definitions": { "BaseUrl": { "title": "Base URL", - "description": "The base URL of a Block Protocol ontology type (the $id of the schema, without the versioned suffix). It should a valid URL, with a trailing slash.", + "description": "The base URL of a Block Protocol ontology type (the $id of the schema, without the versioned suffix). It should be a valid URL, with a trailing slash.", "type": "string", "format": "uri", "maxLength": 2048 diff --git a/libs/@local/graph/api/src/rest/mod.rs b/libs/@local/graph/api/src/rest/mod.rs index e776ccb6fe7..62573759508 100644 --- a/libs/@local/graph/api/src/rest/mod.rs +++ b/libs/@local/graph/api/src/rest/mod.rs @@ -329,7 +329,7 @@ pub enum OpenApiQuery<'a> { GetClosedMultiEntityTypes(&'a JsonValue), GetEntityTypeSubgraph(&'a JsonValue), GetEntities(&'a RawJsonValue), - CountEntities(&'a JsonValue), + SummarizeEntities(&'a JsonValue), GetEntitySubgraph(&'a JsonValue), ValidateEntity(&'a JsonValue), DiffEntity(&'a DiffEntityParams), diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index c6320b4466b..0c0653465e3 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -17,15 +17,15 @@ use hash_graph_authorization::policies::{ }; use hash_graph_store::{ entity::{ - CountEntitiesParams, CreateEntityParams, DeleteEntitiesParams, DeletionSummary, - EmptyEntityTypes, EntityPermissions, EntityQueryCursor, EntityQueryPath, - EntityQuerySorting, EntityStore, EntityTypeRetrieval, EntityTypesError, - EntityValidationReport, EntityValidationType, HasPermissionForEntitiesParams, - PatchEntityParams, QueryConversion, QueryEntitiesParams, QueryEntitiesResponse, - QueryEntitySubgraphParams, QueryEntitySubgraphResponse, UpdateEntityEmbeddingsParams, + CreateEntityParams, DeleteEntitiesParams, DeletionSummary, EmptyEntityTypes, + EntityPermissions, EntityQueryCursor, EntityQueryPath, EntityQuerySorting, EntityStore, + EntityTypeRetrieval, EntityTypesError, EntityValidationReport, EntityValidationType, + HasPermissionForEntitiesParams, PatchEntityParams, QueryConversion, QueryEntitiesParams, + QueryEntitiesResponse, QueryEntitySubgraphParams, QueryEntitySubgraphResponse, + SummarizeEntitiesParams, SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, ValidateEntityComponents, ValidateEntityParams, }, - entity_type::{EntityTypeQueryPath, EntityTypeStore as _, IncludeEntityTypeOption}, + entity_type::{EntityTypeStore as _, IncludeEntityTypeOption}, error::{CheckPermissionError, DeletionError, InsertionError, QueryError, UpdateError}, filter::{ Filter, FilterExpression, FilterExpressionList, Parameter, ParameterList, @@ -79,9 +79,7 @@ use type_system::{ ontology::{ InheritanceDepth, data_type::schema::DataTypeReference, - entity_type::{ - ClosedEntityType, ClosedMultiEntityType, EntityTypeUuid, EntityTypeWithMetadata, - }, + entity_type::{ClosedEntityType, ClosedMultiEntityType, EntityTypeUuid}, id::{OntologyTypeUuid, VersionedUrl}, }, principal::{actor::ActorEntityUuid, actor_group::WebId}, @@ -527,105 +525,6 @@ where .add_filter(filter_to_use) .change_context(QueryError)?; - let (count, web_ids, created_by_ids, edition_created_by_ids, type_ids, type_titles) = - if let Some(summary_query) = EntitySummaryQuery::new(&mut compiler, params) { - let (statement, parameters) = compiler.compile(); - let statement = summary_query.statement(&statement); - - let rows = self - .as_client() - .query_raw(&statement, parameters.iter().copied()) - .instrument(tracing::info_span!( - "SELECT", - otel.kind = "client", - db.system = "postgresql", - peer.service = "Postgres", - db.query.text = statement, - )) - .await - .change_context(QueryError)? - .try_collect::>() - .instrument(tracing::trace_span!("collect_entity_summaries")) - .await - .change_context(QueryError)?; - - let summaries = summary_query.decode(rows)?; - - let type_titles = if params.include_type_titles { - let type_uuids = summaries - .type_ids - .as_ref() - .expect("type ids should be present") - .keys() - .map(EntityTypeUuid::from_url) - .collect::>(); - - let mut type_compiler = SelectCompiler::::new( - Some(temporal_axes), - params.include_drafts, - ); - let base_url_idx = - type_compiler.add_selection_path(&EntityTypeQueryPath::BaseUrl); - let version_idx = - type_compiler.add_selection_path(&EntityTypeQueryPath::Version); - let title_idx = type_compiler.add_selection_path(&EntityTypeQueryPath::Title); - - let filter = Filter::In( - FilterExpression::Path { - path: EntityTypeQueryPath::OntologyId, - }, - FilterExpressionList::ParameterList { - parameters: ParameterList::EntityTypeIds(&type_uuids), - }, - ); - type_compiler - .add_filter(&filter) - .change_context(QueryError)?; - - let (statement, parameters) = type_compiler.compile(); - - Some( - self.as_client() - .query_raw(&statement, parameters.iter().copied()) - .instrument(tracing::info_span!( - "SELECT", - otel.kind = "client", - db.system = "postgresql", - peer.service = "Postgres", - db.query.text = statement, - )) - .await - .change_context(QueryError)? - .map_ok(|row| { - ( - VersionedUrl { - base_url: row.get(base_url_idx), - version: row.get(version_idx), - }, - row.get::<_, String>(title_idx), - ) - }) - .try_collect::>() - .instrument(tracing::trace_span!("collect_entity_types")) - .await - .change_context(QueryError)?, - ) - } else { - None - }; - - ( - summaries.count, - summaries.web_ids, - summaries.created_by_ids, - summaries.edition_created_by_ids, - summaries.type_ids.filter(|_| params.include_type_ids), - type_titles, - ) - } else { - (None, None, None, None, None, None) - }; - compiler.set_limit(params.limit); let cursor_parameters = params.sorting.encode().change_context(QueryError)?; @@ -727,12 +626,6 @@ where }, entities, cursor, - count, - web_ids, - created_by_ids, - edition_created_by_ids, - type_ids, - type_titles, // Populated later permissions: None, }) @@ -1417,14 +1310,8 @@ where let QueryEntitiesResponse { entities: root_entities, cursor, - count, closed_multi_entity_types: _, definitions: _, - web_ids, - created_by_ids, - edition_created_by_ids, - type_ids, - type_titles, permissions, } = self .query_entities_impl(&request, &temporal_axes, &policy_components) @@ -1565,12 +1452,6 @@ where None | Some(IncludeEntityTypeOption::Closed) => None, }, cursor, - count, - web_ids, - created_by_ids, - edition_created_by_ids, - type_ids, - type_titles, entity_permissions: if request.include_permissions { debug_assert!(permissions.is_none(), "Should not be populated yet"); @@ -1612,11 +1493,12 @@ where .await } - async fn count_entities( + #[tracing::instrument(level = "info", skip_all)] + async fn summarize_entities( &self, actor_id: ActorEntityUuid, - mut params: CountEntitiesParams<'_>, - ) -> Result> { + mut params: SummarizeEntitiesParams<'_>, + ) -> Result> { let policy_components = PolicyComponents::builder(self) .with_actor(actor_id) .with_action(ActionName::ViewEntity, MergePolicies::Yes) @@ -1638,14 +1520,14 @@ where ); // Apply filter protection when configured - protects sensitive properties (e.g., email) - // from enumeration attacks in count queries. + // from enumeration attacks in summarize_entities queries. let should_apply_protection = !self.settings.filter_protection.is_empty() && !policy_components.is_instance_admin(); let protected_filter; let filter_to_use = if should_apply_protection { // Transform filter to protect against email filtering on Users - // Note: count_entities has no sorting, so only filter protection applies + // Note: summarize_entities has no sorting, so only filter protection applies protected_filter = transform_filter( params.filter.clone(), &self.settings.filter_protection, @@ -1666,14 +1548,13 @@ where .add_filter(filter_to_use) .change_context(QueryError)?; - compiler.add_distinct_selection_with_ordering( - &EntityQueryPath::EditionId, - Distinctness::Distinct, - None, - ); - + let Some(summary_query) = EntitySummaryQuery::new(&mut compiler, ¶ms) else { + return Ok(SummarizeEntitiesResponse::default()); + }; let (statement, parameters) = compiler.compile(); - Ok(self + let statement = summary_query.statement(&statement); + + let rows = self .as_client() .query_raw(&statement, parameters.iter().copied()) .instrument(tracing::info_span!( @@ -1685,8 +1566,21 @@ where )) .await .change_context(QueryError)? - .count() - .await) + .try_collect::>() + .instrument(tracing::trace_span!("collect_entity_summaries")) + .await + .change_context(QueryError)?; + + let summaries = summary_query.decode(rows)?; + + Ok(SummarizeEntitiesResponse { + count: summaries.count, + web_ids: summaries.web_ids, + created_by_ids: summaries.created_by_ids, + edition_created_by_ids: summaries.edition_created_by_ids, + type_ids: summaries.type_ids.filter(|_| params.include_type_ids), + type_titles: summaries.type_titles.filter(|_| params.include_type_titles), + }) } async fn get_entity_by_id( diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/summary.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/summary.rs index 0c26593fdc8..68e9eccefd6 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/summary.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/summary.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use error_stack::{Report, ResultExt as _}; use hash_graph_store::{ - entity::{EntityQueryPath, QueryEntitiesParams}, + entity::{EntityQueryPath, SummarizeEntitiesParams}, entity_type::EntityTypeQueryPath, error::QueryError, subgraph::edges::SharedEdgeKind, @@ -25,8 +25,8 @@ use crate::store::postgres::query::SelectCompiler; /// Aggregated `include_*` summaries of an entity query. /// -/// Each map is only populated when the corresponding flag was requested; `type_ids` is -/// also populated for `include_type_titles` since the title lookup is keyed by it. +/// Each map is populated only when its flag was requested. `type_ids` and `type_titles` +/// share one aggregate branch, so both are populated whenever either flag is set. #[derive(Debug, Default)] pub(crate) struct EntitySummaries { pub count: Option, @@ -34,13 +34,14 @@ pub(crate) struct EntitySummaries { pub created_by_ids: Option>, pub edition_created_by_ids: Option>, pub type_ids: Option>, + pub type_titles: Option>, } /// Discriminant tagging which `UNION ALL` branch produced a result row. /// /// The aggregate statement returns one row set with a fixed layout — `(dimension, -/// dimension_id, dimension_type, matches)` — and this discriminant in column 0 routes -/// each row to the matching [`EntitySummaries`] field during decoding. +/// dimension_id, dimension_type, matches, dimension_title)` — and this discriminant in +/// column 0 routes each row to the matching [`EntitySummaries`] field during decoding. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(i32)] enum Dimension { @@ -71,16 +72,24 @@ impl Dimension { /// required selections, [`Self::statement`] wraps the compiled query, [`Self::decode`] /// turns the result rows into [`EntitySummaries`]. pub(crate) struct EntitySummaryQuery { + edition_id_column: usize, web_id_column: usize, - entity_uuid_column: usize, - draft_id_column: usize, provenance_column: Option, edition_provenance_column: Option, - type_columns: Option<(usize, usize)>, + type_columns: Option, include_count: bool, include_web_ids: bool, } +/// Column indices for the edition cache's parallel type arrays, selected together when +/// `include_type_ids` or `include_type_titles` is requested. +#[derive(Debug, Clone, Copy)] +struct TypeColumns { + versioned_urls: usize, + direct_types: usize, + type_titles: usize, +} + impl EntitySummaryQuery { /// Adds the selections required for the summaries requested in `params` to the /// `compiler`, or returns [`None`] when no summary is requested. @@ -90,7 +99,7 @@ impl EntitySummaryQuery { /// and a limit would truncate the aggregates. pub(crate) fn new( compiler: &mut SelectCompiler<'_, '_, Entity>, - params: &QueryEntitiesParams<'_>, + params: &SummarizeEntitiesParams<'_>, ) -> Option { if !(params.include_count || params.include_web_ids @@ -103,9 +112,8 @@ impl EntitySummaryQuery { } Some(Self { + edition_id_column: compiler.add_selection_path(&EntityQueryPath::EditionId), web_id_column: compiler.add_selection_path(&EntityQueryPath::WebId), - entity_uuid_column: compiler.add_selection_path(&EntityQueryPath::Uuid), - draft_id_column: compiler.add_selection_path(&EntityQueryPath::DraftId), provenance_column: params .include_created_by_ids .then(|| compiler.add_selection_path(&EntityQueryPath::Provenance(None))), @@ -113,14 +121,19 @@ impl EntitySummaryQuery { .include_edition_created_by_ids .then(|| compiler.add_selection_path(&EntityQueryPath::EditionProvenance(None))), type_columns: (params.include_type_ids || params.include_type_titles).then(|| { - ( - compiler.add_selection_path(&EntityQueryPath::EntityTypeEdge { + TypeColumns { + versioned_urls: compiler.add_selection_path(&EntityQueryPath::EntityTypeEdge { edge_kind: SharedEdgeKind::IsOfType, path: EntityTypeQueryPath::VersionedUrl, inheritance_depth: None, }), - compiler.add_selection_path(&EntityQueryPath::DirectTypeCount), - ) + direct_types: compiler.add_selection_path(&EntityQueryPath::DirectTypeCount), + type_titles: compiler.add_selection_path(&EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::Title, + inheritance_depth: None, + }), + } }), include_count: params.include_count, include_web_ids: params.include_web_ids, @@ -129,31 +142,19 @@ impl EntitySummaryQuery { /// Wraps the compiled selection into the aggregate statement. /// - /// The inner selection may emit duplicate rows through filter joins and multiple - /// matching editions; deduplication happens over the entity identity. Edition-scoped - /// columns (edition provenance, type arrays) are not functionally dependent on it, so - /// their presence requires `DISTINCT ON` picking one arbitrary edition per entity; - /// otherwise a plain (hashable) `DISTINCT` suffices. + /// Filter joins can emit duplicate rows for the same edition, so the `hits` CTE + /// deduplicates over `edition_id` rather than the entity identity. This keeps the + /// aggregates driven by the temporal axis: a point-in-time query matches one edition + /// per entity, an unbounded query matches every edition. pub(crate) fn statement(&self, statement: &str) -> String { let aliases = (0..self.column_count()) .map(|index| format!("c{index}")) .collect::>() .join(", "); - let distinct = if self.edition_provenance_column.is_some() || self.type_columns.is_some() { - format!( - "DISTINCT ON (c{}, c{}, c{})", - self.web_id_column, self.entity_uuid_column, self.draft_id_column - ) - } else { - "DISTINCT".to_owned() - }; + let distinct = format!("DISTINCT ON (c{})", self.edition_id_column); - let mut hit_columns = vec![ - format!("c{} AS web_id", self.web_id_column), - format!("c{} AS entity_uuid", self.entity_uuid_column), - format!("c{} AS draft_id", self.draft_id_column), - ]; + let mut hit_columns = vec![format!("c{} AS web_id", self.web_id_column)]; if let Some(column) = self.provenance_column { hit_columns.push(format!("(c{column} ->> 'createdById')::uuid AS created_by")); } @@ -162,9 +163,10 @@ impl EntitySummaryQuery { "(c{column} ->> 'createdById')::uuid AS edition_created_by" )); } - if let Some((versioned_urls_column, direct_types_column)) = self.type_columns { - hit_columns.push(format!("c{versioned_urls_column} AS versioned_urls")); - hit_columns.push(format!("c{direct_types_column} AS direct_types")); + if let Some(columns) = self.type_columns { + hit_columns.push(format!("c{} AS versioned_urls", columns.versioned_urls)); + hit_columns.push(format!("c{} AS direct_types", columns.direct_types)); + hit_columns.push(format!("c{} AS type_titles", columns.type_titles)); } let hit_columns = hit_columns.join(", "); @@ -172,34 +174,36 @@ impl EntitySummaryQuery { if self.include_count { branches.push(format!( "SELECT {}::int4 AS dimension, NULL::uuid AS dimension_id, NULL::text AS \ - dimension_type, count(*) AS matches FROM hits", + dimension_type, count(*) AS matches, NULL::text AS dimension_title FROM hits", Dimension::Count as i32 )); } if self.include_web_ids { branches.push(format!( - "SELECT {}::int4, web_id, NULL::text, count(*) FROM hits GROUP BY web_id", + "SELECT {}::int4, web_id, NULL::text, count(*), NULL::text FROM hits GROUP BY \ + web_id", Dimension::WebIds as i32 )); } if self.provenance_column.is_some() { branches.push(format!( - "SELECT {}::int4, created_by, NULL::text, count(*) FROM hits GROUP BY created_by", + "SELECT {}::int4, created_by, NULL::text, count(*), NULL::text FROM hits GROUP BY \ + created_by", Dimension::CreatedByIds as i32 )); } if self.edition_provenance_column.is_some() { branches.push(format!( - "SELECT {}::int4, edition_created_by, NULL::text, count(*) FROM hits GROUP BY \ - edition_created_by", + "SELECT {}::int4, edition_created_by, NULL::text, count(*), NULL::text FROM hits \ + GROUP BY edition_created_by", Dimension::EditionCreatedByIds as i32 )); } if self.type_columns.is_some() { branches.push(format!( - "SELECT {}::int4, NULL::uuid, type_id.type_id, count(*) FROM hits CROSS JOIN \ - LATERAL unnest(versioned_urls[1:direct_types]) AS type_id (type_id) GROUP BY \ - type_id.type_id", + "SELECT {}::int4, NULL::uuid, t.type_id, count(*), min(t.title) FROM hits CROSS \ + JOIN LATERAL unnest(versioned_urls[1:direct_types], type_titles[1:direct_types]) \ + AS t (type_id, title) GROUP BY t.type_id", Dimension::TypeIds as i32 )); } @@ -219,11 +223,12 @@ impl EntitySummaryQuery { /// `NULL` actor ID produced by an edition with malformed provenance. pub(crate) fn decode(&self, rows: Vec) -> Result> { let mut summaries = EntitySummaries { - count: None, + count: self.include_count.then_some(0), web_ids: self.include_web_ids.then(HashMap::new), created_by_ids: self.provenance_column.is_some().then(HashMap::new), edition_created_by_ids: self.edition_provenance_column.is_some().then(HashMap::new), type_ids: self.type_columns.is_some().then(HashMap::new), + type_titles: self.type_columns.is_some().then(HashMap::new), }; for row in rows { @@ -261,12 +266,18 @@ impl EntitySummaryQuery { } } Some(Dimension::TypeIds) => { + let type_id = row + .try_get::<_, VersionedUrl>(2) + .change_context(QueryError)?; if let Some(type_ids) = &mut summaries.type_ids { - type_ids.insert( - row.try_get::<_, VersionedUrl>(2) - .change_context(QueryError)?, - matches, - ); + type_ids.insert(type_id.clone(), matches); + } + if let Some(type_titles) = &mut summaries.type_titles + && let Some(title) = row + .try_get::<_, Option>(4) + .change_context(QueryError)? + { + type_titles.insert(type_id, title); } } None => { @@ -281,13 +292,13 @@ impl EntitySummaryQuery { fn column_count(&self) -> usize { [ + Some(self.edition_id_column), Some(self.web_id_column), - Some(self.entity_uuid_column), - Some(self.draft_id_column), self.provenance_column, self.edition_provenance_column, - self.type_columns.map(|(versioned_urls, _)| versioned_urls), - self.type_columns.map(|(_, direct_types)| direct_types), + self.type_columns.map(|columns| columns.versioned_urls), + self.type_columns.map(|columns| columns.direct_types), + self.type_columns.map(|columns| columns.type_titles), ] .into_iter() .flatten() @@ -299,7 +310,7 @@ impl EntitySummaryQuery { #[cfg(test)] mod tests { - use super::{Dimension, EntitySummaryQuery}; + use super::{Dimension, EntitySummaryQuery, TypeColumns}; use crate::store::postgres::query::test_helper::trim_whitespace; #[test] @@ -322,12 +333,15 @@ mod tests { #[test] fn statement_all_dimensions() { let summary_query = EntitySummaryQuery { - web_id_column: 0, - entity_uuid_column: 1, - draft_id_column: 2, - provenance_column: Some(3), - edition_provenance_column: Some(4), - type_columns: Some((5, 6)), + edition_id_column: 0, + web_id_column: 1, + provenance_column: Some(2), + edition_provenance_column: Some(3), + type_columns: Some(TypeColumns { + versioned_urls: 4, + direct_types: 5, + type_titles: 6, + }), include_count: true, include_web_ids: true, }; @@ -335,28 +349,30 @@ mod tests { pretty_assertions::assert_eq!( trim_whitespace(&summary_query.statement("SELECT 1")), trim_whitespace( - "WITH hits AS (SELECT DISTINCT ON (c0, c1, c2) - c0 AS web_id, - c1 AS entity_uuid, - c2 AS draft_id, - (c3 ->> 'createdById')::uuid AS created_by, - (c4 ->> 'createdById')::uuid AS edition_created_by, - c5 AS versioned_urls, - c6 AS direct_types + "WITH hits AS (SELECT DISTINCT ON (c0) + c1 AS web_id, + (c2 ->> 'createdById')::uuid AS created_by, + (c3 ->> 'createdById')::uuid AS edition_created_by, + c4 AS versioned_urls, + c5 AS direct_types, + c6 AS type_titles FROM (SELECT 1) AS raw (c0, c1, c2, c3, c4, c5, c6)) SELECT 0::int4 AS dimension, NULL::uuid AS dimension_id, - NULL::text AS dimension_type, count(*) AS matches FROM hits + NULL::text AS dimension_type, count(*) AS matches, + NULL::text AS dimension_title FROM hits UNION ALL - SELECT 1::int4, web_id, NULL::text, count(*) FROM hits GROUP BY web_id + SELECT 1::int4, web_id, NULL::text, count(*), NULL::text FROM hits GROUP BY web_id UNION ALL - SELECT 2::int4, created_by, NULL::text, count(*) FROM hits GROUP BY created_by + SELECT 2::int4, created_by, NULL::text, count(*), NULL::text FROM hits + GROUP BY created_by UNION ALL - SELECT 3::int4, edition_created_by, NULL::text, count(*) FROM hits + SELECT 3::int4, edition_created_by, NULL::text, count(*), NULL::text FROM hits GROUP BY edition_created_by UNION ALL - SELECT 4::int4, NULL::uuid, type_id.type_id, count(*) FROM hits - CROSS JOIN LATERAL unnest(versioned_urls[1:direct_types]) AS type_id (type_id) - GROUP BY type_id.type_id" + SELECT 4::int4, NULL::uuid, t.type_id, count(*), min(t.title) FROM hits + CROSS JOIN LATERAL unnest(versioned_urls[1:direct_types], + type_titles[1:direct_types]) AS t (type_id, title) + GROUP BY t.type_id" ), ); } @@ -364,9 +380,8 @@ mod tests { #[test] fn statement_count_only() { let summary_query = EntitySummaryQuery { - web_id_column: 0, - entity_uuid_column: 1, - draft_id_column: 2, + edition_id_column: 0, + web_id_column: 1, provenance_column: None, edition_provenance_column: None, type_columns: None, @@ -377,13 +392,12 @@ mod tests { pretty_assertions::assert_eq!( trim_whitespace(&summary_query.statement("SELECT 1")), trim_whitespace( - "WITH hits AS (SELECT DISTINCT - c0 AS web_id, - c1 AS entity_uuid, - c2 AS draft_id - FROM (SELECT 1) AS raw (c0, c1, c2)) + "WITH hits AS (SELECT DISTINCT ON (c0) + c1 AS web_id + FROM (SELECT 1) AS raw (c0, c1)) SELECT 0::int4 AS dimension, NULL::uuid AS dimension_id, - NULL::text AS dimension_type, count(*) AS matches FROM hits" + NULL::text AS dimension_type, count(*) AS matches, + NULL::text AS dimension_title FROM hits" ), ); } diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/entity.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/entity.rs index 862f925cef5..f656f7e871b 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/entity.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/entity.rs @@ -43,7 +43,10 @@ impl PostgresQueryPath for EntityQueryPath<'_> { Self::DirectTypeCount | Self::EntityTypeEdge { edge_kind: SharedEdgeKind::IsOfType, - path: EntityTypeQueryPath::BaseUrl | EntityTypeQueryPath::VersionedUrl, + path: + EntityTypeQueryPath::BaseUrl + | EntityTypeQueryPath::VersionedUrl + | EntityTypeQueryPath::Title, inheritance_depth: None, } => { vec![Relation::EntityEditionCache] @@ -143,6 +146,14 @@ impl PostgresQueryPath for EntityQueryPath<'_> { Column::EntityEditionCache(EntityEditionCache::VersionedUrls), None, ), + Self::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::Title, + inheritance_depth: None, + } => ( + Column::EntityEditionCache(EntityEditionCache::TypeTitles), + None, + ), Self::DirectTypeCount => ( Column::EntityEditionCache(EntityEditionCache::DirectTypes), None, diff --git a/libs/@local/graph/postgres-store/tests/deletion/drafts.rs b/libs/@local/graph/postgres-store/tests/deletion/drafts.rs index 4e4208c5e4b..37856507ded 100644 --- a/libs/@local/graph/postgres-store/tests/deletion/drafts.rs +++ b/libs/@local/graph/postgres-store/tests/deletion/drafts.rs @@ -11,7 +11,7 @@ use hash_graph_store::{ use type_system::knowledge::entity::EntityId; use crate::{ - DatabaseTestWrapper, alice, bob, count_entity, create_person, get_deletion_provenance, + DatabaseTestWrapper, alice, bob, count_entities, create_person, get_deletion_provenance, provenance, raw_count_by_draft_id, raw_count_entity_edge, seed, }; @@ -37,8 +37,8 @@ async fn draft_only_entity_promoted_to_full_delete() { draft_id: None, }; - assert!(count_entity(&api, base_id, true).await >= 1); - assert_eq!(count_entity(&api, base_id, false).await, 0); + assert!(count_entities(&api, base_id, true).await >= 1); + assert_eq!(count_entities(&api, base_id, false).await, 0); let summary = api .store @@ -66,7 +66,7 @@ async fn draft_only_entity_promoted_to_full_delete() { } ); - assert_eq!(count_entity(&api, base_id, true).await, 0); + assert_eq!(count_entities(&api, base_id, true).await, 0); assert!( get_deletion_provenance(&api, base_id.web_id, base_id.entity_uuid) .await @@ -114,8 +114,8 @@ async fn draft_of_published_entity_preserves_published() { .draft_id .expect("patch should produce draft_id"); - assert!(count_entity(&api, entity_id, false).await >= 1); - assert!(count_entity(&api, entity_id, true).await >= 2); + assert!(count_entities(&api, entity_id, false).await >= 1); + assert!(count_entities(&api, entity_id, true).await >= 2); // Delete filtering by the specific draft_id let summary = api @@ -145,10 +145,10 @@ async fn draft_of_published_entity_preserves_published() { ); // Published survives - assert!(count_entity(&api, entity_id, false).await >= 1); + assert!(count_entities(&api, entity_id, false).await >= 1); assert_eq!( - count_entity(&api, entity_id, true).await, - count_entity(&api, entity_id, false).await + count_entities(&api, entity_id, true).await, + count_entities(&api, entity_id, false).await ); // Draft temporal metadata gone @@ -184,8 +184,8 @@ async fn include_drafts_false_skips_drafts() { draft_id: None, }; - assert!(count_entity(&api, base_id, true).await >= 1); - assert_eq!(count_entity(&api, base_id, false).await, 0); + assert!(count_entities(&api, base_id, true).await >= 1); + assert_eq!(count_entities(&api, base_id, false).await, 0); let summary = api .store @@ -214,7 +214,7 @@ async fn include_drafts_false_skips_drafts() { ); // Draft still exists - assert!(count_entity(&api, base_id, true).await >= 1); + assert!(count_entities(&api, base_id, true).await >= 1); } /// Does not promote when only some drafts of an entity are matched. @@ -285,7 +285,7 @@ async fn partial_draft_match_not_promoted() { assert_ne!(draft_id_1, draft_id_2); // Published + 2 drafts - assert!(count_entity(&api, entity_id, true).await >= 3); + assert!(count_entities(&api, entity_id, true).await >= 3); // Delete only draft_id_1 let summary = api @@ -321,8 +321,8 @@ async fn partial_draft_match_not_promoted() { assert!(raw_count_by_draft_id(&api, "entity_temporal_metadata", draft_id_2).await > 0); // Published version + unmatched draft survive - assert!(count_entity(&api, entity_id, false).await >= 1); - assert!(count_entity(&api, entity_id, true).await >= 2); + assert!(count_entities(&api, entity_id, false).await >= 1); + assert!(count_entities(&api, entity_id, true).await >= 2); // Not promoted → no tombstone assert!( @@ -366,8 +366,8 @@ async fn published_and_draft_matched_becomes_full_delete() { .await .expect("could not create draft"); - assert!(count_entity(&api, entity_id, false).await >= 1); - assert!(count_entity(&api, entity_id, true).await >= 2); + assert!(count_entities(&api, entity_id, false).await >= 1); + assert!(count_entities(&api, entity_id, true).await >= 2); // Delete with include_drafts=true, filter by entity UUID (no draft_id) → matches both let summary = api @@ -396,8 +396,8 @@ async fn published_and_draft_matched_becomes_full_delete() { } ); - assert_eq!(count_entity(&api, entity_id, true).await, 0); - assert_eq!(count_entity(&api, entity_id, false).await, 0); + assert_eq!(count_entities(&api, entity_id, true).await, 0); + assert_eq!(count_entities(&api, entity_id, false).await, 0); assert!( get_deletion_provenance(&api, entity_id.web_id, entity_id.entity_uuid) .await @@ -476,7 +476,7 @@ async fn mixed_full_and_draft_targets() { ); // A: fully deleted with tombstone - assert_eq!(count_entity(&api, id_a, false).await, 0); + assert_eq!(count_entities(&api, id_a, false).await, 0); assert!( get_deletion_provenance(&api, id_a.web_id, id_a.entity_uuid) .await @@ -484,10 +484,10 @@ async fn mixed_full_and_draft_targets() { ); // B: published survives, draft gone, no tombstone - assert!(count_entity(&api, id_b, false).await >= 1); + assert!(count_entities(&api, id_b, false).await >= 1); assert_eq!( - count_entity(&api, id_b, true).await, - count_entity(&api, id_b, false).await + count_entities(&api, id_b, true).await, + count_entities(&api, id_b, false).await ); assert!( get_deletion_provenance(&api, id_b.web_id, id_b.entity_uuid) @@ -560,7 +560,7 @@ async fn empty_target_guards() { } ); - assert!(count_entity(&api, entity_id, false).await >= 1); + assert!(count_entities(&api, entity_id, false).await >= 1); assert_eq!( raw_count_by_draft_id(&api, "entity_temporal_metadata", draft_id).await, 0 @@ -650,7 +650,7 @@ async fn draft_link_entity_edge_survives() { assert!( raw_count_entity_edge(&api, link_entity_id.web_id, link_entity_id.entity_uuid).await > 0 ); - assert!(count_entity(&api, link_entity_id, false).await >= 1); + assert!(count_entities(&api, link_entity_id, false).await >= 1); // Step 2: Delete the published entity → full deletion, edge cleaned up let summary2 = api @@ -684,7 +684,7 @@ async fn draft_link_entity_edge_survives() { raw_count_entity_edge(&api, link_entity_id.web_id, link_entity_id.entity_uuid).await, 0 ); - assert_eq!(count_entity(&api, link_entity_id, true).await, 0); + assert_eq!(count_entities(&api, link_entity_id, true).await, 0); } /// `DeletionSummary.draft_deletions` counts individual draft IDs, not entities. @@ -751,7 +751,7 @@ async fn summary_counts_draft_ids_not_entities() { assert_ne!(draft_id_1, draft_id_2); // Published + 2 drafts - assert!(count_entity(&api, entity_id, true).await >= 3); + assert!(count_entities(&api, entity_id, true).await >= 3); // Delete both drafts (but not the published version) let summary = api @@ -784,10 +784,10 @@ async fn summary_counts_draft_ids_not_entities() { ); // Published version survives - assert!(count_entity(&api, entity_id, false).await >= 1); + assert!(count_entities(&api, entity_id, false).await >= 1); // No drafts remain assert_eq!( - count_entity(&api, entity_id, true).await, - count_entity(&api, entity_id, false).await + count_entities(&api, entity_id, true).await, + count_entities(&api, entity_id, false).await ); } diff --git a/libs/@local/graph/postgres-store/tests/deletion/erase.rs b/libs/@local/graph/postgres-store/tests/deletion/erase.rs index 5fd136bde3c..bd9604d46ef 100644 --- a/libs/@local/graph/postgres-store/tests/deletion/erase.rs +++ b/libs/@local/graph/postgres-store/tests/deletion/erase.rs @@ -14,7 +14,7 @@ use type_system::knowledge::property::{ }; use crate::{ - DatabaseTestWrapper, alice, count_entity, create_person, get_deletion_provenance, + DatabaseTestWrapper, alice, count_entities, create_person, get_deletion_provenance, person_type_id, provenance, raw_count, raw_entity_ids_exists, seed, }; @@ -56,7 +56,7 @@ async fn removes_entity_ids_row() { } ); - assert_eq!(count_entity(&api, entity_id, false).await, 0); + assert_eq!(count_entities(&api, entity_id, false).await, 0); assert!(!raw_entity_ids_exists(&api, entity_id.web_id, entity_id.entity_uuid).await); } @@ -156,7 +156,7 @@ async fn entity_with_history() { .await .expect("could not patch entity"); - assert!(count_entity(&api, entity_id, false).await >= 2); + assert!(count_entities(&api, entity_id, false).await >= 2); let web_id = entity_id.web_id; let entity_uuid = entity_id.entity_uuid; @@ -189,7 +189,7 @@ async fn entity_with_history() { } ); - assert_eq!(count_entity(&api, entity_id, false).await, 0); + assert_eq!(count_entities(&api, entity_id, false).await, 0); assert_eq!( raw_count(&api, "entity_temporal_metadata", web_id, entity_uuid).await, 0, @@ -317,7 +317,7 @@ async fn entity_reuse_after_erase() { .expect("re-creating entity with same UUID should succeed"); let new_id = new_entity.metadata.record_id.entity_id; - assert!(count_entity(&api, new_id, false).await >= 1); + assert!(count_entities(&api, new_id, false).await >= 1); // Verify it's fully functional by patching api.store @@ -357,7 +357,7 @@ async fn promoted_draft_only_entity() { draft_id: None, }; - assert!(count_entity(&api, base_entity_id, true).await >= 1); + assert!(count_entities(&api, base_entity_id, true).await >= 1); let summary = api .store @@ -383,7 +383,7 @@ async fn promoted_draft_only_entity() { } ); - assert_eq!(count_entity(&api, base_entity_id, true).await, 0); + assert_eq!(count_entities(&api, base_entity_id, true).await, 0); assert!(!raw_entity_ids_exists(&api, entity_id.web_id, entity_id.entity_uuid).await); } @@ -427,7 +427,7 @@ async fn erase_partial_draft_preserves_entity_ids() { let draft_entity_id = patched.metadata.record_id.entity_id; assert!(draft_entity_id.draft_id.is_some()); - assert!(count_entity(&api, entity_id, true).await >= 2); + assert!(count_entities(&api, entity_id, true).await >= 2); // Erase filtering only the draft let summary = api @@ -455,10 +455,10 @@ async fn erase_partial_draft_preserves_entity_ids() { ); // Published version survives - assert!(count_entity(&api, entity_id, false).await >= 1); + assert!(count_entities(&api, entity_id, false).await >= 1); assert_eq!( - count_entity(&api, entity_id, true).await, - count_entity(&api, entity_id, false).await + count_entities(&api, entity_id, true).await, + count_entities(&api, entity_id, false).await ); // entity_ids is NOT deleted despite Erase scope (draft-only target) diff --git a/libs/@local/graph/postgres-store/tests/deletion/links.rs b/libs/@local/graph/postgres-store/tests/deletion/links.rs index a793883a8da..135d261b77f 100644 --- a/libs/@local/graph/postgres-store/tests/deletion/links.rs +++ b/libs/@local/graph/postgres-store/tests/deletion/links.rs @@ -12,7 +12,7 @@ use hash_graph_store::{ }; use crate::{ - DatabaseTestWrapper, alice, bob, count_entity, create_link, create_person, + DatabaseTestWrapper, alice, bob, count_entities, create_link, create_person, get_deletion_provenance, has_any_live_temporal_row, has_archived_provenance, is_entity_live, provenance, raw_count, raw_count_archived_temporal_rows, raw_count_entity_edge, raw_count_entity_edge_any, raw_entity_ids_exists, seed, @@ -58,7 +58,7 @@ async fn purge_error_rejects_with_incoming_links() { ); // Entity B must be completely intact after the error (transaction rolled back) - assert!(count_entity(&api, id_b, false).await >= 1); + assert!(count_entities(&api, id_b, false).await >= 1); assert!(raw_entity_ids_exists(&api, id_b.web_id, id_b.entity_uuid).await); assert!( get_deletion_provenance(&api, id_b.web_id, id_b.entity_uuid) @@ -162,7 +162,7 @@ async fn erase_rejects_with_incoming_links() { ); // Entity B must be completely intact after the error (transaction rolled back) - assert!(count_entity(&api, id_b, false).await >= 1); + assert!(count_entities(&api, id_b, false).await >= 1); assert!(raw_entity_ids_exists(&api, id_b.web_id, id_b.entity_uuid).await); assert!( get_deletion_provenance(&api, id_b.web_id, id_b.entity_uuid) @@ -382,7 +382,7 @@ async fn draft_deletion_skips_link_check() { ); // Published B still exists - assert!(count_entity(&api, id_b, false).await >= 1); + assert!(count_entities(&api, id_b, false).await >= 1); // Link L→B still valid assert!(raw_count_entity_edge_any(&api, id_link.web_id, id_link.entity_uuid).await > 0); diff --git a/libs/@local/graph/postgres-store/tests/deletion/main.rs b/libs/@local/graph/postgres-store/tests/deletion/main.rs index 13a63534168..b8177e163a7 100644 --- a/libs/@local/graph/postgres-store/tests/deletion/main.rs +++ b/libs/@local/graph/postgres-store/tests/deletion/main.rs @@ -17,7 +17,7 @@ use hash_graph_postgres_store::store::{AsClient as _, PostgresStore}; use hash_graph_store::{ account::{AccountStore as _, CreateUserActorParams}, data_type::{CreateDataTypeParams, DataTypeStore as _}, - entity::{CountEntitiesParams, CreateEntityParams, EntityStore as _}, + entity::{CreateEntityParams, EntityStore as _, SummarizeEntitiesParams}, entity_type::{CreateEntityTypeParams, EntityTypeStore as _}, error::InsertionError, filter::Filter, @@ -296,22 +296,30 @@ pub(crate) async fn seed(database: &mut DatabaseTestWrapper) -> DatabaseApi<'_> .expect("could not seed database") } -pub(crate) async fn count_entity( +pub(crate) async fn count_entities( api: &DatabaseApi<'_>, entity_id: EntityId, include_drafts: bool, ) -> usize { api.store - .count_entities( + .summarize_entities( api.account_id, - CountEntitiesParams { + SummarizeEntitiesParams { filter: Filter::for_entity_by_entity_id(entity_id), temporal_axes: QueryTemporalAxesUnresolved::all(), include_drafts, + include_count: true, + include_web_ids: false, + include_created_by_ids: false, + include_edition_created_by_ids: false, + include_type_ids: false, + include_type_titles: false, }, ) .await .expect("could not count entities") + .count + .expect("summarize_entities should include `count` when `include_count` is true") } pub(crate) async fn create_person( diff --git a/libs/@local/graph/postgres-store/tests/deletion/purge.rs b/libs/@local/graph/postgres-store/tests/deletion/purge.rs index d9edc4020af..d6dff77e3e6 100644 --- a/libs/@local/graph/postgres-store/tests/deletion/purge.rs +++ b/libs/@local/graph/postgres-store/tests/deletion/purge.rs @@ -23,9 +23,9 @@ use type_system::{ use uuid::Uuid; use crate::{ - DatabaseTestWrapper, alice, bob, count_entity, create_link, create_person, create_second_user, - get_deletion_provenance, get_inferred_provenance, person_type_id, provenance, raw_count, - raw_entity_ids_exists, seed, + DatabaseTestWrapper, alice, bob, count_entities, create_link, create_person, + create_second_user, get_deletion_provenance, get_inferred_provenance, person_type_id, + provenance, raw_count, raw_entity_ids_exists, seed, }; /// Helper: purge with default settings (`include_drafts=false`, Ignore link behavior). @@ -77,7 +77,7 @@ async fn published_entity() { .expect("could not create entity"); let entity_id = entity.metadata.record_id.entity_id; - assert_eq!(count_entity(&api, entity_id, false).await, 1); + assert_eq!(count_entities(&api, entity_id, false).await, 1); let summary = api .store @@ -104,7 +104,7 @@ async fn published_entity() { links_archived: 0, } ); - assert_eq!(count_entity(&api, entity_id, false).await, 0); + assert_eq!(count_entities(&api, entity_id, false).await, 0); } /// Purges an entity that was updated, producing 2 temporal editions. @@ -163,7 +163,7 @@ async fn published_entity_with_history() { .expect("could not update entity"); // Unbounded temporal query shows both editions - assert_eq!(count_entity(&api, entity_id, false).await, 2); + assert_eq!(count_entities(&api, entity_id, false).await, 2); let web_id = entity_id.web_id; let entity_uuid = entity_id.entity_uuid; @@ -198,7 +198,7 @@ async fn published_entity_with_history() { } ); // All history gone — both via read path and raw table count - assert_eq!(count_entity(&api, entity_id, false).await, 0); + assert_eq!(count_entities(&api, entity_id, false).await, 0); assert_eq!( raw_count(&api, "entity_temporal_metadata", web_id, entity_uuid).await, 0, @@ -306,8 +306,8 @@ async fn include_drafts_irrelevant_for_published() { }; assert_eq!(summary_a, expected); assert_eq!(summary_b, expected); - assert_eq!(count_entity(&api, id_a, false).await, 0); - assert_eq!(count_entity(&api, id_b, false).await, 0); + assert_eq!(count_entities(&api, id_a, false).await, 0); + assert_eq!(count_entities(&api, id_b, false).await, 0); } /// `Purge` with [`LinkDeletionBehavior::Error`] succeeds when no incoming links exist. @@ -349,7 +349,7 @@ async fn purge_error_succeeds_without_incoming_links() { links_archived: 0, } ); - assert_eq!(count_entity(&api, entity_id, false).await, 0); + assert_eq!(count_entities(&api, entity_id, false).await, 0); assert!(raw_entity_ids_exists(&api, entity_id.web_id, entity_id.entity_uuid).await); } @@ -548,8 +548,8 @@ async fn multiple_entities_in_batch() { links_archived: 0, } ); - assert_eq!(count_entity(&api, id_a, false).await, 0); - assert_eq!(count_entity(&api, id_b, false).await, 0); + assert_eq!(count_entities(&api, id_a, false).await, 0); + assert_eq!(count_entities(&api, id_b, false).await, 0); // Both tombstoned assert!(raw_entity_ids_exists(&api, id_a.web_id, id_a.entity_uuid).await); @@ -588,8 +588,8 @@ async fn other_entity_unaffected() { } ); - assert_eq!(count_entity(&api, id_a, false).await, 0); - assert_eq!(count_entity(&api, id_b, false).await, 1); + assert_eq!(count_entities(&api, id_a, false).await, 0); + assert_eq!(count_entities(&api, id_b, false).await, 1); // B's satellite data intact let web_b = id_b.web_id; @@ -655,10 +655,10 @@ async fn batch_with_mixed_entity_states() { } ); - assert_eq!(count_entity(&api, id_a, false).await, 0); - assert_eq!(count_entity(&api, id_b, false).await, 0); - assert_eq!(count_entity(&api, id_link, false).await, 0); - assert_eq!(count_entity(&api, id_draft, true).await, 0); + assert_eq!(count_entities(&api, id_a, false).await, 0); + assert_eq!(count_entities(&api, id_b, false).await, 0); + assert_eq!(count_entities(&api, id_link, false).await, 0); + assert_eq!(count_entities(&api, id_draft, true).await, 0); // All tombstoned assert!(raw_entity_ids_exists(&api, id_a.web_id, id_a.entity_uuid).await); @@ -733,8 +733,8 @@ async fn cross_web_batch() { assert!(raw_entity_ids_exists(&api, id_b.web_id, id_b.entity_uuid).await); // Satellite data gone for both (read path AND raw table) - assert_eq!(count_entity(&api, id_a, false).await, 0); - assert_eq!(count_entity(&api, id_b, false).await, 0); + assert_eq!(count_entities(&api, id_a, false).await, 0); + assert_eq!(count_entities(&api, id_b, false).await, 0); assert_eq!( raw_count( &api, @@ -784,8 +784,8 @@ async fn query_after_purge_returns_empty() { // count_entity exercises the full read path (SelectCompiler → SQL) // If the tombstone caused a read error, this would panic instead of returning 0 - assert_eq!(count_entity(&api, entity_id, false).await, 0); - assert_eq!(count_entity(&api, entity_id, true).await, 0); + assert_eq!(count_entities(&api, entity_id, false).await, 0); + assert_eq!(count_entities(&api, entity_id, true).await, 0); } /// Verifies provenance records the deleting actor, not the creating actor. @@ -933,7 +933,7 @@ async fn large_batch() { ); for id in &ids { - assert_eq!(count_entity(&api, *id, false).await, 0); + assert_eq!(count_entities(&api, *id, false).await, 0); } } @@ -986,11 +986,11 @@ async fn filter_by_entity_type() { ); // Both persons deleted - assert_eq!(count_entity(&api, id_a, false).await, 0); - assert_eq!(count_entity(&api, id_b, false).await, 0); + assert_eq!(count_entities(&api, id_a, false).await, 0); + assert_eq!(count_entities(&api, id_b, false).await, 0); // Link entity survives (different type) - assert!(count_entity(&api, id_link, false).await >= 1); + assert!(count_entities(&api, id_link, false).await >= 1); } /// Purges an entity that was previously archived. @@ -1043,7 +1043,7 @@ async fn archived_entity() { } ); - assert_eq!(count_entity(&api, entity_id, false).await, 0); + assert_eq!(count_entities(&api, entity_id, false).await, 0); assert!(raw_entity_ids_exists(&api, entity_id.web_id, entity_id.entity_uuid).await); } diff --git a/libs/@local/graph/postgres-store/tests/deletion/validation.rs b/libs/@local/graph/postgres-store/tests/deletion/validation.rs index 5400dfbae6c..e32e5f46411 100644 --- a/libs/@local/graph/postgres-store/tests/deletion/validation.rs +++ b/libs/@local/graph/postgres-store/tests/deletion/validation.rs @@ -19,7 +19,7 @@ use type_system::knowledge::property::{ }; use crate::{ - DatabaseTestWrapper, alice, bob, count_entity, get_deletion_provenance, person_type_id, + DatabaseTestWrapper, alice, bob, count_entities, get_deletion_provenance, person_type_id, provenance, raw_count, seed, }; @@ -237,7 +237,7 @@ async fn decision_time_before_creation_finds_nothing() { ); // Entity still exists - assert!(count_entity(&api, entity_id, false).await >= 1); + assert!(count_entities(&api, entity_id, false).await >= 1); } /// Past `decision_time` still deletes ALL temporal editions, not just the one alive at that time. @@ -347,7 +347,7 @@ async fn past_decision_time_deletes_all_editions() { 0, "all temporal editions must be deleted, not just the one alive at decision_time" ); - assert_eq!(count_entity(&api, entity_id, false).await, 0); + assert_eq!(count_entities(&api, entity_id, false).await, 0); let deletion = get_deletion_provenance(&api, entity_id.web_id, entity_id.entity_uuid) .await diff --git a/libs/@local/graph/sdk/typescript/src/entity.ts b/libs/@local/graph/sdk/typescript/src/entity.ts index 5cc9b662208..d27b8506462 100644 --- a/libs/@local/graph/sdk/typescript/src/entity.ts +++ b/libs/@local/graph/sdk/typescript/src/entity.ts @@ -99,6 +99,8 @@ import type { QueryEntitySubgraphRequest as QueryEntitySubgraphRequestGraphApi, QueryEntitySubgraphResponse as QueryEntitySubgraphResponseGraphApi, ValidateEntityParams, + SummarizeEntitiesParams, + SummarizeEntitiesResponse as SummarizeEntitiesResponseGraphApi, } from "@local/hash-graph-client"; import type { CreateEntityPolicyParams, @@ -234,24 +236,11 @@ export type QueryEntitiesResponse< TypeIdsAndPropertiesForEntity, > = DistributiveOmit< QueryEntitiesResponseGraphApi, - | "entities" - | "closedMultiEntityTypes" - | "definitions" - | "webIds" - | "createdByIds" - | "editionCreatedByIds" - | "typeIds" - | "typeTitles" - | "permissions" + "entities" | "closedMultiEntityTypes" | "definitions" | "permissions" > & { entities: HashEntity[]; closedMultiEntityTypes?: Record; definitions?: EntityTypeResolveDefinitions; - webIds?: Record; - createdByIds?: Record; - editionCreatedByIds?: Record; - typeIds?: Record; - typeTitles?: Record; permissions?: EntityPermissionsMap; }; @@ -279,24 +268,23 @@ export type QueryEntitySubgraphResponse< TypeIdsAndPropertiesForEntity, > = DistributiveOmit< QueryEntitySubgraphResponseGraphApi, - | "subgraph" - | "closedMultiEntityTypes" - | "definitions" - | "webIds" - | "createdByIds" - | "editionCreatedByIds" - | "typeIds" - | "typeTitles" + "subgraph" | "closedMultiEntityTypes" | "definitions" > & { subgraph: Subgraph>, HashEntity>; closedMultiEntityTypes?: Record; definitions?: EntityTypeResolveDefinitions; + entityPermissions?: EntityPermissionsMap; +}; + +export type SummarizeEntitiesResponse = DistributiveOmit< + SummarizeEntitiesResponseGraphApi, + "webIds" | "createdByIds" | "editionCreatedByIds" | "typeIds" | "typeTitles" +> & { webIds?: Record; createdByIds?: Record; editionCreatedByIds?: Record; typeIds?: Record; typeTitles?: Record; - entityPermissions?: EntityPermissionsMap; }; export type SerializedQueryEntitySubgraphResponse = DistributiveOmit< @@ -363,17 +351,6 @@ export const queryEntitySubgraph = async < response.definitions, ) : undefined, - webIds: response.webIds as Record | undefined, - createdByIds: response.createdByIds as - | Record - | undefined, - editionCreatedByIds: response.editionCreatedByIds as - | Record - | undefined, - typeIds: response.typeIds as Record | undefined, - typeTitles: response.typeTitles as - | Record - | undefined, entityPermissions: response.entityPermissions as | EntityPermissionsMap | undefined, @@ -1501,7 +1478,6 @@ export class HashLinkEntity< return super.linkData!; } } - export const queryEntities = async < PropertyMap extends TypeIdsAndPropertiesForEntity = TypeIdsAndPropertiesForEntity, @@ -1533,6 +1509,27 @@ export const queryEntities = async < response.definitions, ) : undefined, + permissions: response.permissions as EntityPermissionsMap | undefined, + })); +}; + +export const summarizeEntities = async ( + context: { + graphApi: GraphApi; + temporalClient?: TemporalClient; + }, + authentication: AuthenticationContext, + params: SummarizeEntitiesParams, +): Promise => { + if (Predicate.hasProperty(params, "filter")) { + // TODO: https://linear.app/hash/issue/BE-108/consider-moving-semantic-filter-rewriting-to-the-graph + await rewriteSemanticFilter(params.filter, context.temporalClient); + } + + return context.graphApi + .summarizeEntities(authentication.actorId, params) + .then(({ data: response }) => ({ + ...response, webIds: response.webIds as Record | undefined, createdByIds: response.createdByIds as | Record @@ -1544,7 +1541,6 @@ export const queryEntities = async < typeTitles: response.typeTitles as | Record | undefined, - permissions: response.permissions as EntityPermissionsMap | undefined, })); }; diff --git a/libs/@local/graph/store/src/entity/mod.rs b/libs/@local/graph/store/src/entity/mod.rs index 886e40201f5..f9c2c24f4f1 100644 --- a/libs/@local/graph/store/src/entity/mod.rs +++ b/libs/@local/graph/store/src/entity/mod.rs @@ -4,12 +4,13 @@ pub use self::{ EntityQuerySortingToken, EntityQueryToken, }, store::{ - ClosedMultiEntityTypeMap, CountEntitiesParams, CreateEntityParams, DeleteEntitiesParams, - DeletionScope, DeletionSummary, DiffEntityParams, DiffEntityResult, EntityPermissions, - EntityStore, EntityValidationType, HasPermissionForEntitiesParams, LinkDeletionBehavior, + ClosedMultiEntityTypeMap, CreateEntityParams, DeleteEntitiesParams, DeletionScope, + DeletionSummary, DiffEntityParams, DiffEntityResult, EntityPermissions, EntityStore, + EntityValidationType, HasPermissionForEntitiesParams, LinkDeletionBehavior, PatchEntityParams, QueryConversion, QueryEntitiesParams, QueryEntitiesResponse, - QueryEntitySubgraphParams, QueryEntitySubgraphResponse, UpdateEntityEmbeddingsParams, - ValidateEntityComponents, ValidateEntityError, ValidateEntityParams, + QueryEntitySubgraphParams, QueryEntitySubgraphResponse, SummarizeEntitiesParams, + SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, ValidateEntityComponents, + ValidateEntityError, ValidateEntityParams, }, validation_report::{ EmptyEntityTypes, EntityRetrieval, EntityTypeRetrieval, EntityTypesError, diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index 349e73270a0..90e9efa87ab 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -189,7 +189,6 @@ pub struct QueryConversion<'a> { } #[derive(Debug)] -#[expect(clippy::struct_excessive_bools, reason = "Parameter struct")] pub struct QueryEntitiesParams<'a> { pub filter: Filter<'a, Entity>, pub temporal_axes: QueryTemporalAxesUnresolved, @@ -197,13 +196,7 @@ pub struct QueryEntitiesParams<'a> { pub conversions: Vec>, pub limit: usize, pub include_drafts: bool, - pub include_count: bool, pub include_entity_types: Option, - pub include_web_ids: bool, - pub include_created_by_ids: bool, - pub include_edition_created_by_ids: bool, - pub include_type_ids: bool, - pub include_type_titles: bool, pub include_permissions: bool, } @@ -261,30 +254,12 @@ pub struct QueryEntitiesResponse<'r> { pub cursor: Option>, #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "utoipa", schema(nullable = false))] - pub count: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "utoipa", schema(nullable = false))] pub closed_multi_entity_types: Option>, #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "utoipa", schema(nullable = false))] pub definitions: Option, #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "utoipa", schema(nullable = false))] - pub web_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "utoipa", schema(nullable = false))] - pub created_by_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "utoipa", schema(nullable = false))] - pub edition_created_by_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "utoipa", schema(nullable = false))] - pub type_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "utoipa", schema(nullable = false))] - pub type_titles: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[cfg_attr(feature = "utoipa", schema(nullable = false))] pub permissions: Option>, } @@ -413,25 +388,56 @@ impl<'a> QueryEntitySubgraphParams<'a> { pub struct QueryEntitySubgraphResponse<'r> { pub subgraph: Subgraph, pub cursor: Option>, - pub count: Option, pub closed_multi_entity_types: Option>, pub definitions: Option, - pub web_ids: Option>, - pub created_by_ids: Option>, - pub edition_created_by_ids: Option>, - pub type_ids: Option>, - pub type_titles: Option>, pub entity_permissions: Option>, } #[derive(Debug, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct CountEntitiesParams<'a> { +#[expect(clippy::struct_excessive_bools, reason = "Parameter struct")] +pub struct SummarizeEntitiesParams<'a> { #[serde(borrow)] pub filter: Filter<'a, Entity>, pub temporal_axes: QueryTemporalAxesUnresolved, pub include_drafts: bool, + #[serde(default)] + pub include_count: bool, + #[serde(default)] + pub include_web_ids: bool, + #[serde(default)] + pub include_created_by_ids: bool, + #[serde(default)] + pub include_edition_created_by_ids: bool, + #[serde(default)] + pub include_type_ids: bool, + #[serde(default)] + pub include_type_titles: bool, +} + +#[derive(Debug, Default, Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct SummarizeEntitiesResponse { + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "utoipa", schema(nullable = false))] + pub count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "utoipa", schema(nullable = false))] + pub web_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "utoipa", schema(nullable = false))] + pub created_by_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "utoipa", schema(nullable = false))] + pub edition_created_by_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "utoipa", schema(nullable = false))] + pub type_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "utoipa", schema(nullable = false))] + pub type_titles: Option>, } #[derive(Debug, Deserialize)] @@ -725,18 +731,18 @@ pub trait EntityStore { params: QueryEntitySubgraphParams<'_>, ) -> impl Future, Report>> + Send; - /// Count the number of entities that would be returned in [`query_entities`]. + /// Summarizes the entities that would be returned in [`query_entities`]. /// /// # Errors /// /// - if the request to the database fails /// /// [`query_entities`]: Self::query_entities - fn count_entities( + fn summarize_entities( &self, actor_id: ActorEntityUuid, - params: CountEntitiesParams<'_>, - ) -> impl Future>> + Send; + params: SummarizeEntitiesParams<'_>, + ) -> impl Future>> + Send; fn get_entity_by_id( &self, diff --git a/libs/@local/graph/type-fetcher/src/store.rs b/libs/@local/graph/type-fetcher/src/store.rs index 3510811fd71..862892aae24 100644 --- a/libs/@local/graph/type-fetcher/src/store.rs +++ b/libs/@local/graph/type-fetcher/src/store.rs @@ -35,10 +35,11 @@ use hash_graph_store::{ UnarchiveDataTypeParams, UpdateDataTypeEmbeddingParams, UpdateDataTypesParams, }, entity::{ - CountEntitiesParams, CreateEntityParams, DeleteEntitiesParams, DeletionSummary, - EntityStore, EntityValidationReport, HasPermissionForEntitiesParams, PatchEntityParams, + CreateEntityParams, DeleteEntitiesParams, DeletionSummary, EntityStore, + EntityValidationReport, HasPermissionForEntitiesParams, PatchEntityParams, QueryEntitiesParams, QueryEntitiesResponse, QueryEntitySubgraphParams, - QueryEntitySubgraphResponse, UpdateEntityEmbeddingsParams, ValidateEntityParams, + QueryEntitySubgraphResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, + UpdateEntityEmbeddingsParams, ValidateEntityParams, }, entity_type::{ ArchiveEntityTypeParams, CommonQueryEntityTypesParams, CountEntityTypesParams, @@ -1650,12 +1651,12 @@ where .await } - async fn count_entities( + async fn summarize_entities( &self, actor_id: ActorEntityUuid, - params: CountEntitiesParams<'_>, - ) -> Result> { - self.store.count_entities(actor_id, params).await + params: SummarizeEntitiesParams<'_>, + ) -> Result> { + self.store.summarize_entities(actor_id, params).await } async fn patch_entity( diff --git a/libs/@local/hash-backend-utils/src/flows.ts b/libs/@local/hash-backend-utils/src/flows.ts index 7e504c4f65d..a433a37f6be 100644 --- a/libs/@local/hash-backend-utils/src/flows.ts +++ b/libs/@local/hash-backend-utils/src/flows.ts @@ -4,7 +4,8 @@ import { splitEntityId, } from "@blockprotocol/type-system"; import { typedKeys } from "@local/advanced-types/typed-entries"; -import { queryEntities } from "@local/hash-graph-sdk/entity"; +import { rewriteSemanticFilter } from "@local/hash-graph-sdk/embeddings"; +import { queryEntities, summarizeEntities } from "@local/hash-graph-sdk/entity"; import { flowRunsQueryMaxLimit } from "@local/hash-isomorphic-utils/flows/types"; import { currentTimeInstantTemporalAxes, @@ -191,36 +192,42 @@ export async function getFlowRuns({ flowRunsQueryMaxLimit, ); - const queryResult = await queryEntities( + const filter = { + all: [ + generateVersionedUrlMatchingFilter( + systemEntityTypes.flowRun.entityTypeId, + { ignoreParents: true }, + ), + ...(filters.flowDefinitionIds + ? [ + { + any: filters.flowDefinitionIds.map((flowDefinitionId) => ({ + equal: [ + { + path: [ + "properties", + systemPropertyTypes.flowDefinitionId.propertyTypeBaseUrl, + ], + }, + { parameter: flowDefinitionId }, + ], + })), + }, + ] + : []), + ], + }; + + // `queryEntities` and `summarizeEntities` both rewrite semantic (embedding) filters + // internally; resolve it once here so the embedding lookup isn't run twice for the two + // concurrent requests (and so the shared filter isn't mutated under them). + await rewriteSemanticFilter(filter, temporalClient); + + const entityQuery = queryEntities( { graphApi: graphApiClient }, authentication, { - filter: { - all: [ - generateVersionedUrlMatchingFilter( - systemEntityTypes.flowRun.entityTypeId, - { ignoreParents: true }, - ), - ...(filters.flowDefinitionIds - ? [ - { - any: filters.flowDefinitionIds.map((flowDefinitionId) => ({ - equal: [ - { - path: [ - "properties", - systemPropertyTypes.flowDefinitionId - .propertyTypeBaseUrl, - ], - }, - { parameter: flowDefinitionId }, - ], - })), - }, - ] - : []), - ], - }, + filter, temporalAxes: currentTimeInstantTemporalAxes, includeDrafts: false, includePermissions: false, @@ -228,10 +235,25 @@ export async function getFlowRuns({ ...(filters.cursor ? { cursor: JSON.parse(filters.cursor) as object[] } : {}), + }, + ); + + const summaryQuery = summarizeEntities( + { graphApi: graphApiClient }, + authentication, + { + filter, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: false, includeCount: true, }, ); + const [queryResult, summaryResult] = await Promise.all([ + entityQuery, + summaryQuery, + ]); + const temporalWorkflowIdToFlowDetails: Record = {}; @@ -261,7 +283,7 @@ export async function getFlowRuns({ const nextCursor = queryResult.cursor ? JSON.stringify(queryResult.cursor) : null; - const totalCount = queryResult.count ?? 0; + const totalCount = summaryResult.count ?? 0; if (!temporalWorkflowIds.length) { return { flowRuns: [], totalCount, nextCursor }; diff --git a/libs/@local/hash-isomorphic-utils/src/graphql/scalar-mapping.ts b/libs/@local/hash-isomorphic-utils/src/graphql/scalar-mapping.ts index 037699f7725..c2e360b5c6c 100644 --- a/libs/@local/hash-isomorphic-utils/src/graphql/scalar-mapping.ts +++ b/libs/@local/hash-isomorphic-utils/src/graphql/scalar-mapping.ts @@ -68,7 +68,9 @@ export const scalars = { EntityMetadata: "@blockprotocol/type-system#EntityMetadata", EntityValidationReport: "@local/hash-graph-sdk/validation#EntityValidationReport", - CountEntitiesParams: "@local/hash-graph-client#CountEntitiesParams", + SummarizeEntitiesParams: "@local/hash-graph-client#SummarizeEntitiesParams", + SummarizeEntitiesResponse: + "@local/hash-graph-sdk/entity#SummarizeEntitiesResponse", QueryEntitiesRequest: "@local/hash-graph-sdk/entity#QueryEntitiesRequest", QueryEntitiesResponse: "@local/hash-graph-sdk/entity#SerializedQueryEntitiesResponse", diff --git a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/entity.typedef.ts b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/entity.typedef.ts index 4d90ba47c65..b79c06a381b 100644 --- a/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/entity.typedef.ts +++ b/libs/@local/hash-isomorphic-utils/src/graphql/type-defs/knowledge/entity.typedef.ts @@ -3,7 +3,8 @@ import { gql } from "graphql-tag"; export const entityTypedef = gql` scalar ClosedMultiEntityTypesRootMap scalar ClosedMultiEntityTypesDefinitions - scalar CountEntitiesParams + scalar SummarizeEntitiesParams + scalar SummarizeEntitiesResponse scalar CreatedByIdsMap scalar EntityId scalar EntityMetadata @@ -109,7 +110,9 @@ export const entityTypedef = gql` } extend type Query { - countEntities(request: CountEntitiesParams!): Int! + summarizeEntities( + request: SummarizeEntitiesParams! + ): SummarizeEntitiesResponse! queryEntities(request: QueryEntitiesRequest!): QueryEntitiesResponse! diff --git a/libs/@local/hashql/mir/src/interpret/suspension/mod.rs b/libs/@local/hashql/mir/src/interpret/suspension/mod.rs index e86885d5343..b539630b2dc 100644 --- a/libs/@local/hashql/mir/src/interpret/suspension/mod.rs +++ b/libs/@local/hashql/mir/src/interpret/suspension/mod.rs @@ -20,7 +20,7 @@ mod graph_read; mod temporal; -use core::{alloc::Allocator, debug_assert_matches}; +use core::alloc::Allocator; pub(crate) use self::graph_read::extract_axis; pub use self::temporal::{TemporalAxesInterval, TemporalInterval, Timestamp}; @@ -106,7 +106,7 @@ impl<'ctx, 'heap, A: Allocator> Continuation<'ctx, 'heap, A> { let current_statement = frame.current_statement; debug_assert_eq!(current_block.block.statements.len(), current_statement); - debug_assert_matches!( + core::debug_assert_matches!( current_block.block.terminator.kind, TerminatorKind::GraphRead(_) ); diff --git a/tests/graph/benches/graph/scenario/stages/entity_queries.rs b/tests/graph/benches/graph/scenario/stages/entity_queries.rs index 6b014663fb7..ba9249ef0b8 100644 --- a/tests/graph/benches/graph/scenario/stages/entity_queries.rs +++ b/tests/graph/benches/graph/scenario/stages/entity_queries.rs @@ -120,13 +120,7 @@ impl QueryEntitiesByUserStage { conversions: Vec::new(), limit: self.inputs.limit, include_drafts: false, - include_count: false, include_entity_types: None, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, ) diff --git a/tests/graph/benches/manual_queries/entity_queries/all-public-subgraph.json b/tests/graph/benches/manual_queries/entity_queries/all-public-subgraph.json index cfe57997aa9..0a1b51149e7 100644 --- a/tests/graph/benches/manual_queries/entity_queries/all-public-subgraph.json +++ b/tests/graph/benches/manual_queries/entity_queries/all-public-subgraph.json @@ -6,7 +6,6 @@ "parameters": { "actorId": [], "limit": [], - "includeCount": [], "traversalParams": [ { "graphResolveDepths": { diff --git a/tests/graph/benches/manual_queries/entity_queries/all-public.json b/tests/graph/benches/manual_queries/entity_queries/all-public.json index 3700e3ffe2f..5a167d775a7 100644 --- a/tests/graph/benches/manual_queries/entity_queries/all-public.json +++ b/tests/graph/benches/manual_queries/entity_queries/all-public.json @@ -5,8 +5,7 @@ "sampleSize": 10, "parameters": { "actorId": [], - "limit": [10, 100, 500], - "includeCount": [false] + "limit": [10, 100, 500] } }, "request": { diff --git a/tests/graph/benches/manual_queries/entity_queries/mod.rs b/tests/graph/benches/manual_queries/entity_queries/mod.rs index ff0e4d76963..d03a19d57b7 100644 --- a/tests/graph/benches/manual_queries/entity_queries/mod.rs +++ b/tests/graph/benches/manual_queries/entity_queries/mod.rs @@ -122,8 +122,6 @@ struct QueryEntitiesQueryParameters { actor_id: Vec, #[serde(default)] limit: Vec, - #[serde(default)] - include_count: Vec, } #[derive(Debug, Clone, serde::Deserialize)] @@ -140,7 +138,6 @@ impl QueryEntitiesQuery<'_, '_, '_> { fn prepare_request(mut self) -> impl Iterator { let modifies_actor_id = !self.settings.parameters.actor_id.is_empty(); let modifies_limit = !self.settings.parameters.limit.is_empty(); - let modifies_include_count = !self.settings.parameters.include_count.is_empty(); let (query, options) = self.request.into_parts(); @@ -156,12 +153,8 @@ impl QueryEntitiesQuery<'_, '_, '_> { ) .sorted() .dedup(); - let include_count = iter::once(options.include_count) - .chain(mem::take(&mut self.settings.parameters.include_count)) - .sorted() - .dedup(); - iproduct!(actor_id, limit, include_count).map(move |(actor_id, limit, include_count)| { + iproduct!(actor_id, limit).map(move |(actor_id, limit)| { let mut parameters = Vec::new(); if modifies_actor_id { parameters.push(format!("actor_id={actor_id}")); @@ -169,9 +162,6 @@ impl QueryEntitiesQuery<'_, '_, '_> { if modifies_limit && let Some(limit) = limit { parameters.push(format!("limit={limit}")); } - if modifies_include_count { - parameters.push(format!("include_count={include_count}")); - } ( Self { actor_id, @@ -179,7 +169,6 @@ impl QueryEntitiesQuery<'_, '_, '_> { query.clone(), EntityQueryOptions { limit, - include_count, ..options.clone() }, ), @@ -199,8 +188,6 @@ struct QueryEntitySubgraphQueryParameters { #[serde(default)] limit: Vec, #[serde(default)] - include_count: Vec, - #[serde(default)] traversal_params: Vec, } @@ -249,7 +236,6 @@ impl QueryEntitySubgraphQuery<'_, '_, '_> { fn prepare_request(mut self) -> impl Iterator { let modifies_actor_id = !self.settings.parameters.actor_id.is_empty(); let modifies_limit = !self.settings.parameters.limit.is_empty(); - let modifies_include_count = !self.settings.parameters.include_count.is_empty(); let modifies_graph_resolve_depths = !self.settings.parameters.traversal_params.is_empty(); let (query, options, traversal_params) = self.request.clone().into_parts(); @@ -266,15 +252,11 @@ impl QueryEntitySubgraphQuery<'_, '_, '_> { ) .sorted() .dedup(); - let include_count = iter::once(options.include_count) - .chain(mem::take(&mut self.settings.parameters.include_count)) - .sorted() - .dedup(); let traversal_params_iter = iter::once(traversal_params) .chain(mem::take(&mut self.settings.parameters.traversal_params)); - iproduct!(actor_id, limit, include_count, traversal_params_iter).map( - move |(actor_id, limit, include_count, traversal_params)| { + iproduct!(actor_id, limit, traversal_params_iter).map( + move |(actor_id, limit, traversal_params)| { let mut parameters = Vec::new(); if modifies_actor_id { parameters.push(format!("actor_id={actor_id}")); @@ -282,9 +264,6 @@ impl QueryEntitySubgraphQuery<'_, '_, '_> { if modifies_limit && let Some(limit) = limit { parameters.push(format!("limit={limit}")); } - if modifies_include_count { - parameters.push(format!("include_count={include_count}")); - } if modifies_graph_resolve_depths { parameters.push(format_traversal_params(&traversal_params)); } @@ -295,7 +274,6 @@ impl QueryEntitySubgraphQuery<'_, '_, '_> { query.clone(), EntityQueryOptions { limit, - include_count, ..options.clone() }, traversal_params, diff --git a/tests/graph/benches/read_scaling/knowledge/complete/entity.rs b/tests/graph/benches/read_scaling/knowledge/complete/entity.rs index 5c70ead13c3..fdb166fc609 100644 --- a/tests/graph/benches/read_scaling/knowledge/complete/entity.rs +++ b/tests/graph/benches/read_scaling/knowledge/complete/entity.rs @@ -249,14 +249,8 @@ pub fn bench_get_entity_by_id( }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, traversal_params, diff --git a/tests/graph/benches/read_scaling/knowledge/linkless/entity.rs b/tests/graph/benches/read_scaling/knowledge/linkless/entity.rs index e3f546a2edd..3188f5ed399 100644 --- a/tests/graph/benches/read_scaling/knowledge/linkless/entity.rs +++ b/tests/graph/benches/read_scaling/knowledge/linkless/entity.rs @@ -184,14 +184,8 @@ pub fn bench_get_entity_by_id( }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, ) diff --git a/tests/graph/benches/representative_read/knowledge/entity.rs b/tests/graph/benches/representative_read/knowledge/entity.rs index c767eb86353..f1fdaa7f644 100644 --- a/tests/graph/benches/representative_read/knowledge/entity.rs +++ b/tests/graph/benches/representative_read/knowledge/entity.rs @@ -54,14 +54,8 @@ pub fn bench_get_entity_by_id( }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, ) @@ -109,14 +103,8 @@ pub fn bench_query_entities_by_property( }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, traversal_params, @@ -169,14 +157,8 @@ pub fn bench_get_link_by_target_by_property( }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, traversal_params, diff --git a/tests/graph/integration/postgres/email_filter_protection.rs b/tests/graph/integration/postgres/email_filter_protection.rs index f69b2a83833..1e510d934d2 100644 --- a/tests/graph/integration/postgres/email_filter_protection.rs +++ b/tests/graph/integration/postgres/email_filter_protection.rs @@ -1,7 +1,3 @@ -#![expect( - clippy::large_futures, - reason = "Test verification futures are large due to filter complexity; acceptable for tests" -)] #![expect( clippy::print_stderr, reason = "eprintln! used for debug output on test failures" @@ -23,8 +19,8 @@ use std::collections::HashSet; use hash_graph_postgres_store::store::PostgresStoreSettings; use hash_graph_store::{ entity::{ - CountEntitiesParams, CreateEntityParams, EntityQueryPath, EntityQuerySorting, - EntityQuerySortingRecord, EntityStore as _, QueryEntitiesParams, QueryEntitySubgraphParams, + CreateEntityParams, EntityQueryPath, EntityQuerySorting, EntityQuerySortingRecord, + EntityStore as _, QueryEntitiesParams, QueryEntitySubgraphParams, SummarizeEntitiesParams, }, entity_type::EntityTypeQueryPath, filter::{ @@ -387,14 +383,8 @@ impl DatabaseApi<'_> { sorting, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, ) @@ -404,16 +394,24 @@ impl DatabaseApi<'_> { } async fn count(&self, filter: Filter<'_, Entity>) -> usize { - self.count_entities( + self.summarize_entities( self.account_id, - CountEntitiesParams { + SummarizeEntitiesParams { filter, temporal_axes: standard_temporal_axes(), include_drafts: false, + include_count: true, + include_web_ids: false, + include_created_by_ids: false, + include_edition_created_by_ids: false, + include_type_ids: false, + include_type_titles: false, }, ) .await .expect("count failed") + .count + .expect("summarize_entities should include `count` when `include_count` is true") } } @@ -3168,14 +3166,8 @@ async fn subgraph_traversal_masks_linked_user_email() { }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, }, diff --git a/tests/graph/integration/postgres/entity.rs b/tests/graph/integration/postgres/entity.rs index 9532e829ad0..c71c146ccb5 100644 --- a/tests/graph/integration/postgres/entity.rs +++ b/tests/graph/integration/postgres/entity.rs @@ -2,8 +2,8 @@ use std::collections::HashSet; use hash_graph_store::{ entity::{ - CountEntitiesParams, CreateEntityParams, EntityQuerySorting, EntityStore as _, - PatchEntityParams, QueryEntitiesParams, + CreateEntityParams, EntityQuerySorting, EntityStore as _, PatchEntityParams, + QueryEntitiesParams, SummarizeEntitiesParams, }, filter::Filter, subgraph::temporal_axes::{ @@ -102,14 +102,8 @@ async fn insert() { }, limit: 1000, conversions: Vec::new(), - include_count: true, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -181,14 +175,8 @@ async fn query() { }, limit: 1000, conversions: Vec::new(), - include_count: true, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -280,16 +268,24 @@ async fn update() { .expect("could not update entity"); let num_entities = api - .count_entities( + .summarize_entities( api.account_id, - CountEntitiesParams { + SummarizeEntitiesParams { filter: Filter::for_entity_by_entity_id(v2_entity.metadata.record_id.entity_id), temporal_axes: QueryTemporalAxesUnresolved::all(), include_drafts: false, + include_count: true, + include_web_ids: false, + include_created_by_ids: false, + include_edition_created_by_ids: false, + include_type_ids: false, + include_type_titles: false, }, ) .await - .expect("could not count entities"); + .expect("could not count entities") + .count + .expect("summarize_entities should include `count` when `include_count` is true"); assert_eq!(num_entities, 2); let entities = Box::pin(api.query_entities( @@ -303,14 +299,8 @@ async fn update() { }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -342,20 +332,14 @@ async fn update() { }, limit: 1000, conversions: Vec::new(), - include_count: true, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) .await .expect("could not get entities"); - assert_eq!(response_v1.count, Some(1)); + assert_eq!(response_v1.entities.len(), 1); let entity_v1 = response_v1.entities.pop().expect("no entity found"); assert_eq!(entity_v1.properties.properties(), page_v1.properties()); @@ -378,20 +362,68 @@ async fn update() { }, limit: 1000, conversions: Vec::new(), - include_count: true, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) .await .expect("could not get entities"); - assert_eq!(response_v2.count, Some(1)); + + // Pinned to a single point in decision time, only one edition matches, so the count is + // per entity (contrast with the unbounded query below, which counts every edition). + let point_in_time_count = api + .summarize_entities( + api.account_id, + SummarizeEntitiesParams { + filter: Filter::for_entity_by_entity_id(v2_entity.metadata.record_id.entity_id), + temporal_axes: QueryTemporalAxesUnresolved::DecisionTime { + pinned: PinnedTemporalAxisUnresolved::new(None), + variable: VariableTemporalAxisUnresolved::new( + Some(TemporalBound::Inclusive(entity_v2_timestamp)), + Some(LimitedTemporalBound::Inclusive(entity_v2_timestamp)), + ), + }, + include_drafts: false, + include_count: true, + include_web_ids: false, + include_created_by_ids: false, + include_edition_created_by_ids: false, + include_type_ids: false, + include_type_titles: false, + }, + ) + .await + .expect("could not count entities") + .count; + assert_eq!(point_in_time_count, Some(1)); + + let num_entities = api + .summarize_entities( + api.account_id, + SummarizeEntitiesParams { + filter: Filter::for_entity_by_entity_id(v2_entity.metadata.record_id.entity_id), + temporal_axes: QueryTemporalAxesUnresolved::DecisionTime { + pinned: PinnedTemporalAxisUnresolved::new(None), + variable: VariableTemporalAxisUnresolved::new( + Some(TemporalBound::Unbounded), + None, + ), + }, + include_drafts: false, + include_count: true, + include_web_ids: false, + include_created_by_ids: false, + include_edition_created_by_ids: false, + include_type_ids: false, + include_type_titles: false, + }, + ) + .await + .expect("could not count entities") + .count + .expect("summarize_entities should include `count` when `include_count` is true"); + assert_eq!(num_entities, 2); let entity_v2 = response_v2.entities.pop().expect("no entity found"); assert_eq!(entity_v2.properties.properties(), page_v2.properties()); } diff --git a/tests/graph/integration/postgres/lib.rs b/tests/graph/integration/postgres/lib.rs index dfe5f90446e..670fb0aebbe 100644 --- a/tests/graph/integration/postgres/lib.rs +++ b/tests/graph/integration/postgres/lib.rs @@ -43,10 +43,11 @@ use hash_graph_store::{ UnarchiveDataTypeParams, UpdateDataTypeEmbeddingParams, UpdateDataTypesParams, }, entity::{ - CountEntitiesParams, CreateEntityParams, DeleteEntitiesParams, DeletionSummary, - EntityStore, EntityValidationReport, HasPermissionForEntitiesParams, PatchEntityParams, + CreateEntityParams, DeleteEntitiesParams, DeletionSummary, EntityStore, + EntityValidationReport, HasPermissionForEntitiesParams, PatchEntityParams, QueryEntitiesParams, QueryEntitiesResponse, QueryEntitySubgraphParams, - QueryEntitySubgraphResponse, UpdateEntityEmbeddingsParams, ValidateEntityParams, + QueryEntitySubgraphResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, + UpdateEntityEmbeddingsParams, ValidateEntityParams, }, entity_type::{ ArchiveEntityTypeParams, CountEntityTypesParams, CreateEntityTypeParams, EntityTypeStore, @@ -763,82 +764,25 @@ impl EntityStore for DatabaseApi<'_> { async fn query_entities( &self, actor_id: ActorEntityUuid, - mut params: QueryEntitiesParams<'_>, + params: QueryEntitiesParams<'_>, ) -> Result, Report> { - let include_count = params.include_count; - let is_first_page = params.sorting.cursor.is_none(); - let limit = params.limit; - params.include_count = true; - - let count = self - .count_entities( - actor_id, - CountEntitiesParams { - filter: params.filter.clone(), - temporal_axes: params.temporal_axes, - include_drafts: params.include_drafts, - }, - ) - .await?; - - let mut response = self.store.query_entities(actor_id, params).await?; - - // We can ensure that `count_entities` and `get_entity` return the same count; - assert_eq!(response.count, Some(count)); - // if this is the first page and the limit is large enough, all entities should be returned - if is_first_page && count <= limit { - assert_eq!(count, response.entities.len()); - } - - if !include_count { - response.count = None; - } - Ok(response) + self.store.query_entities(actor_id, params).await } async fn query_entity_subgraph( &self, actor_id: ActorEntityUuid, - mut params: QueryEntitySubgraphParams<'_>, + params: QueryEntitySubgraphParams<'_>, ) -> Result, Report> { - let request = params.request_mut(); - - let include_count = request.include_count; - let is_first_page = request.sorting.cursor.is_none(); - let limit = request.limit; - request.include_count = true; - - let count = self - .count_entities( - actor_id, - CountEntitiesParams { - filter: request.filter.clone(), - temporal_axes: request.temporal_axes, - include_drafts: request.include_drafts, - }, - ) - .await?; - let mut response = self.store.query_entity_subgraph(actor_id, params).await?; - - // We can ensure that `count_entities` and `get_entity` return the same count; - assert_eq!(response.count, Some(count)); - // if this is the first page and the limit is large enough, all entities should be returned - if is_first_page && count <= limit { - assert_eq!(count, response.subgraph.roots.len()); - } - - if !include_count { - response.count = None; - } - Ok(response) + self.store.query_entity_subgraph(actor_id, params).await } - async fn count_entities( + async fn summarize_entities( &self, actor_id: ActorEntityUuid, - params: CountEntitiesParams<'_>, - ) -> Result> { - self.store.count_entities(actor_id, params).await + params: SummarizeEntitiesParams<'_>, + ) -> Result> { + self.store.summarize_entities(actor_id, params).await } async fn get_entity_by_id( diff --git a/tests/graph/integration/postgres/links.rs b/tests/graph/integration/postgres/links.rs index 081bec9fa45..5aebe4d5ec5 100644 --- a/tests/graph/integration/postgres/links.rs +++ b/tests/graph/integration/postgres/links.rs @@ -3,8 +3,8 @@ use std::collections::HashSet; use hash_graph_store::{ entity::{ - CountEntitiesParams, CreateEntityParams, EntityQueryPath, EntityQuerySorting, - EntityStore as _, PatchEntityParams, QueryEntitiesParams, + CreateEntityParams, EntityQueryPath, EntityQuerySorting, EntityStore as _, + PatchEntityParams, QueryEntitiesParams, SummarizeEntitiesParams, }, entity_type::EntityTypeQueryPath, filter::{Filter, FilterExpression, Parameter}, @@ -231,14 +231,8 @@ async fn insert() { }, limit: 1000, conversions: Vec::new(), - include_count: true, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -486,14 +480,8 @@ async fn get_entity_links() { }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -667,9 +655,9 @@ async fn remove_link() { .expect("could not create link"); let has_link = api - .count_entities( + .summarize_entities( api.account_id, - CountEntitiesParams { + SummarizeEntitiesParams { filter: Filter::All(vec![ Filter::Equal( FilterExpression::Path { @@ -698,10 +686,18 @@ async fn remove_link() { ]), temporal_axes: QueryTemporalAxesUnresolved::live_only(), include_drafts: false, + include_count: true, + include_web_ids: false, + include_created_by_ids: false, + include_edition_created_by_ids: false, + include_type_ids: false, + include_type_titles: false, }, ) .await .expect("could not count entities") + .count + .expect("summarize_entities should include `count` when `include_count` is true") > 0; assert!(has_link); @@ -726,9 +722,9 @@ async fn remove_link() { .expect("could not remove link"); let has_link = api - .count_entities( + .summarize_entities( api.account_id, - CountEntitiesParams { + SummarizeEntitiesParams { filter: Filter::All(vec![ Filter::Equal( FilterExpression::Path { @@ -757,10 +753,18 @@ async fn remove_link() { ]), temporal_axes: QueryTemporalAxesUnresolved::live_only(), include_drafts: false, + include_count: true, + include_web_ids: false, + include_created_by_ids: false, + include_edition_created_by_ids: false, + include_type_ids: false, + include_type_titles: false, }, ) .await .expect("could not count entities") + .count + .expect("summarize_entities should include `count` when `include_count` is true") > 0; assert!(!has_link); } diff --git a/tests/graph/integration/postgres/multi_type.rs b/tests/graph/integration/postgres/multi_type.rs index cb84e37d0fe..54c2cbc8e83 100644 --- a/tests/graph/integration/postgres/multi_type.rs +++ b/tests/graph/integration/postgres/multi_type.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use hash_graph_store::{ entity::{ CreateEntityParams, EntityQuerySorting, EntityStore as _, PatchEntityParams, - QueryEntitiesParams, + QueryEntitiesParams, SummarizeEntitiesParams, }, error::InsertionError, filter::Filter, @@ -150,14 +150,8 @@ async fn initial_person() { }, limit: 1000, conversions: Vec::new(), - include_count: true, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -212,14 +206,8 @@ async fn initial_person() { }, limit: 1000, conversions: Vec::new(), - include_count: true, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -238,14 +226,8 @@ async fn initial_person() { }, limit: 1000, conversions: Vec::new(), - include_count: true, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -318,14 +300,8 @@ async fn create_multi() { }, limit: 1000, conversions: Vec::new(), - include_count: true, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -344,14 +320,8 @@ async fn create_multi() { }, limit: 1000, conversions: Vec::new(), - include_count: true, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -401,14 +371,8 @@ async fn create_multi() { }, limit: 1000, conversions: Vec::new(), - include_count: true, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -473,32 +437,23 @@ async fn summary_aggregations() { .await .expect("could not create entity"); - let response = Box::pin(api.query_entities( + let response = Box::pin(api.summarize_entities( api.account_id, - QueryEntitiesParams { + SummarizeEntitiesParams { filter: Filter::for_entity_by_type_id(&person_entity_type_id()), temporal_axes: QueryTemporalAxesUnresolved::live_only(), - sorting: EntityQuerySorting { - paths: Vec::new(), - cursor: None, - }, - limit: 1000, - conversions: Vec::new(), include_count: true, - include_entity_types: None, include_drafts: false, include_web_ids: true, include_created_by_ids: true, include_edition_created_by_ids: true, include_type_ids: true, include_type_titles: true, - include_permissions: false, }, )) .await .expect("could not get entities"); - assert_eq!(response.entities.len(), 2); assert_eq!(response.count, Some(2)); assert_eq!( response.web_ids, @@ -527,26 +482,18 @@ async fn summary_aggregations() { ])) ); - let titles_only = Box::pin(api.query_entities( + let titles_only = Box::pin(api.summarize_entities( api.account_id, - QueryEntitiesParams { + SummarizeEntitiesParams { filter: Filter::for_entity_by_type_id(&person_entity_type_id()), temporal_axes: QueryTemporalAxesUnresolved::live_only(), - sorting: EntityQuerySorting { - paths: Vec::new(), - cursor: None, - }, - limit: 1000, - conversions: Vec::new(), include_count: false, - include_entity_types: None, include_drafts: false, include_web_ids: false, include_created_by_ids: false, include_edition_created_by_ids: false, include_type_ids: false, include_type_titles: true, - include_permissions: false, }, )) .await diff --git a/tests/graph/integration/postgres/partial_updates.rs b/tests/graph/integration/postgres/partial_updates.rs index fed1662cade..ae87e13a538 100644 --- a/tests/graph/integration/postgres/partial_updates.rs +++ b/tests/graph/integration/postgres/partial_updates.rs @@ -169,14 +169,8 @@ async fn properties_add() { }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -261,14 +255,8 @@ async fn properties_remove() { }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -355,14 +343,8 @@ async fn properties_replace() { }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -442,14 +424,8 @@ async fn type_ids() { }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -497,14 +473,8 @@ async fn type_ids() { }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) @@ -554,14 +524,8 @@ async fn type_ids() { }, limit: 1000, conversions: Vec::new(), - include_count: false, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, )) diff --git a/tests/graph/integration/postgres/sorting.rs b/tests/graph/integration/postgres/sorting.rs index c2bc5119b1b..6fe3aa1d518 100644 --- a/tests/graph/integration/postgres/sorting.rs +++ b/tests/graph/integration/postgres/sorting.rs @@ -54,7 +54,6 @@ async fn test_root_sorting( }) .collect::>(); let mut cursor = None; - let expected_order = expected_order.into_iter().collect::>(); let mut found_entities = HashSet::new(); let mut entities = Vec::new(); @@ -62,15 +61,9 @@ async fn test_root_sorting( loop { let QueryEntitySubgraphResponse { mut subgraph, - count, cursor: new_cursor, closed_multi_entity_types: _, definitions: _, - web_ids: _, - created_by_ids: _, - edition_created_by_ids: _, - type_ids: _, - type_titles: _, entity_permissions: _, } = Box::pin(api.query_entity_subgraph( api.account_id, @@ -85,14 +78,8 @@ async fn test_root_sorting( }, limit: chunk_size, conversions: Vec::new(), - include_count: true, include_entity_types: None, include_drafts: false, - include_web_ids: false, - include_created_by_ids: false, - include_edition_created_by_ids: false, - include_type_ids: false, - include_type_titles: false, include_permissions: false, }, }, @@ -111,7 +98,6 @@ async fn test_root_sorting( | GraphElementVertexId::EntityType(_) => unreachable!(), }) .collect::>(); - assert_eq!(count, Some(expected_order.len())); let num_entities = new_entities.len(); for entity in new_entities {