diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 3d4c93a04f7..7893ed31257 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -1648,16 +1648,6 @@ "$ref": "#/components/schemas/ActorEntityUuid" } }, - { - "name": "Interactive", - "in": "header", - "description": "Whether the request is used interactively", - "required": false, - "schema": { - "type": "boolean", - "nullable": true - } - }, { "name": "after", "in": "query", @@ -1776,16 +1766,6 @@ "$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", @@ -4600,78 +4580,6 @@ "type": "object" } }, - "EntityQueryOptions": { - "type": "object", - "required": [ - "temporalAxes", - "includeDrafts", - "includePermissions" - ], - "properties": { - "conversions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QueryConversion" - } - }, - "cursor": { - "allOf": [ - { - "$ref": "#/components/schemas/EntityQueryCursor" - } - ], - "nullable": true - }, - "includeCount": { - "type": "boolean" - }, - "includeCreatedByIds": { - "type": "boolean" - }, - "includeDrafts": { - "type": "boolean" - }, - "includeEditionCreatedByIds": { - "type": "boolean" - }, - "includeEntityTypes": { - "allOf": [ - { - "$ref": "#/components/schemas/IncludeEntityTypeOption" - } - ], - "nullable": true - }, - "includePermissions": { - "type": "boolean" - }, - "includeTypeIds": { - "type": "boolean" - }, - "includeTypeTitles": { - "type": "boolean" - }, - "includeWebIds": { - "type": "boolean" - }, - "limit": { - "type": "integer", - "nullable": true, - "minimum": 0 - }, - "sortingPaths": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EntityQuerySortingRecord" - }, - "nullable": true - }, - "temporalAxes": { - "$ref": "#/components/schemas/QueryTemporalAxesUnresolved" - } - }, - "additionalProperties": false - }, "EntityQuerySortingPath": { "type": "array", "items": { @@ -7991,42 +7899,80 @@ } }, "QueryEntitiesRequest": { - "oneOf": [ - { + "type": "object", + "required": [ + "filter", + "temporalAxes", + "includeDrafts", + "includePermissions" + ], + "properties": { + "conversions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QueryConversion" + } + }, + "cursor": { "allOf": [ { - "$ref": "#/components/schemas/EntityQueryOptions" - }, - { - "type": "object", - "required": [ - "query" - ], - "properties": { - "query": {} - } + "$ref": "#/components/schemas/EntityQueryCursor" } - ] + ], + "nullable": true }, - { + "filter": { + "$ref": "#/components/schemas/Filter" + }, + "includeCount": { + "type": "boolean" + }, + "includeCreatedByIds": { + "type": "boolean" + }, + "includeDrafts": { + "type": "boolean" + }, + "includeEditionCreatedByIds": { + "type": "boolean" + }, + "includeEntityTypes": { "allOf": [ { - "$ref": "#/components/schemas/EntityQueryOptions" - }, - { - "type": "object", - "required": [ - "filter" - ], - "properties": { - "filter": { - "$ref": "#/components/schemas/Filter" - } - } + "$ref": "#/components/schemas/IncludeEntityTypeOption" } - ] + ], + "nullable": true + }, + "includePermissions": { + "type": "boolean" + }, + "includeTypeIds": { + "type": "boolean" + }, + "includeTypeTitles": { + "type": "boolean" + }, + "includeWebIds": { + "type": "boolean" + }, + "limit": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "sortingPaths": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityQuerySortingRecord" + }, + "nullable": true + }, + "temporalAxes": { + "$ref": "#/components/schemas/QueryTemporalAxesUnresolved" } - ] + }, + "additionalProperties": false }, "QueryEntitiesResponse": { "type": "object", @@ -8112,46 +8058,15 @@ { "allOf": [ { - "$ref": "#/components/schemas/EntityQueryOptions" - }, - { - "type": "object", - "required": [ - "query", - "traversalPaths", - "graphResolveDepths" - ], - "properties": { - "graphResolveDepths": { - "$ref": "#/components/schemas/GraphResolveDepths" - }, - "query": {}, - "traversalPaths": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EntityTraversalPath" - } - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/EntityQueryOptions" + "$ref": "#/components/schemas/QueryEntitiesRequest" }, { "type": "object", "required": [ - "filter", "traversalPaths", "graphResolveDepths" ], "properties": { - "filter": { - "$ref": "#/components/schemas/Filter" - }, "graphResolveDepths": { "$ref": "#/components/schemas/GraphResolveDepths" }, @@ -8168,41 +8083,14 @@ { "allOf": [ { - "$ref": "#/components/schemas/EntityQueryOptions" - }, - { - "type": "object", - "required": [ - "query", - "traversalPaths" - ], - "properties": { - "query": {}, - "traversalPaths": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TraversalPath" - } - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/EntityQueryOptions" + "$ref": "#/components/schemas/QueryEntitiesRequest" }, { "type": "object", "required": [ - "filter", "traversalPaths" ], "properties": { - "filter": { - "$ref": "#/components/schemas/Filter" - }, "traversalPaths": { "type": "array", "items": { diff --git a/libs/@local/graph/api/src/rest/entity.rs b/libs/@local/graph/api/src/rest/entity/mod.rs similarity index 63% rename from libs/@local/graph/api/src/rest/entity.rs rename to libs/@local/graph/api/src/rest/entity/mod.rs index 28b88f015db..17e12fb342e 100644 --- a/libs/@local/graph/api/src/rest/entity.rs +++ b/libs/@local/graph/api/src/rest/entity/mod.rs @@ -1,5 +1,7 @@ //! Web routes for CRU operations on entities. +pub mod query; + use alloc::sync::Arc; use std::collections::HashMap; @@ -20,7 +22,6 @@ use hash_graph_store::{ UnexpectedEntityType, UpdateEntityEmbeddingsParams, ValidateEntityComponents, ValidateEntityParams, }, - entity_type::EntityTypeResolveDefinitions, pool::StorePool, query::{NullOrdering, Ordering}, }; @@ -40,9 +41,7 @@ use hash_graph_types::{ }, }; use hash_temporal_client::TemporalClient; -use hashql_core::heap::Heap; -use serde::{Deserialize as _, Serialize}; -use serde_json::value::RawValue as RawJsonvalue; +use serde::Deserialize as _; use type_system::{ knowledge::{ Confidence, Entity, Property, @@ -66,36 +65,30 @@ 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}; -pub use crate::rest::entity_query_request::{ - EntityQuery, EntityQueryOptions, QueryEntitiesRequest, QueryEntitySubgraphRequest, +use self::query::{ + QueryEntitySubgraphResponse, count_entities, query_entities, query_entity_subgraph, + request::{QueryEntitiesRequest, QueryEntitySubgraphRequest}, }; use crate::rest::{ - ApiConfig, AuthenticatedUserHeader, InteractiveHeader, OpenApiQuery, QueryLogger, - entity_query_request::CompilationOptions, + AuthenticatedUserHeader, OpenApiQuery, QueryLogger, json::Json, status::{BoxedResponse, report_to_response}, - utoipa_typedef::subgraph::Subgraph, }; -#[derive(OpenApi)] +#[derive(utoipa::OpenApi)] #[openapi( paths( create_entity, create_entities, validate_entity, has_permission_for_entities, - query_entities, - query_entity_subgraph, - count_entities, + self::query::query_entities, + self::query::query_entity_subgraph, + self::query::count_entities, patch_entity, update_entity_embeddings, diff_entity, @@ -121,7 +114,6 @@ use crate::rest::{ HasPermissionForEntitiesParams, - EntityQueryOptions, QueryEntitiesRequest, QueryEntitySubgraphRequest, EntityQueryCursor, @@ -403,265 +395,6 @@ where .map_err(report_to_response) } -#[utoipa::path( - post, - path = "/entities/query", - request_body = QueryEntitiesRequest, - tag = "Entity", - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - ("Interactive" = Option, Header, description = "Whether the request is used interactively"), - ("after" = Option, Query, description = "The cursor to start reading from"), - ("limit" = Option, Query, description = "The maximum number of entities to read"), - ), - responses( - ( - status = 200, - content_type = "application/json", - body = QueryEntitiesResponse, - description = "A list of entities that satisfy the given query.", - ), - (status = 422, content_type = "text/plain", description = "Provided query is invalid"), - (status = 500, description = "Store error occurred"), - ) -)] -async fn query_entities( - AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - InteractiveHeader(interactive): InteractiveHeader, - store_pool: Extension>, - temporal_client: Extension>>, - Extension(api_config): Extension, - mut query_logger: Option>, - Json(request): Json>, -) -> Result>, BoxedResponse> -where - S: StorePool + Send + Sync, -{ - if let Some(query_logger) = &mut query_logger { - query_logger.capture(actor_id, OpenApiQuery::GetEntities(&request)); - } - - let store = store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)?; - - let request = QueryEntitiesRequest::deserialize(&*request) - .map_err(Report::from) - .map_err(report_to_response)?; - - let (query, options) = request.into_parts(); - - // TODO: https://linear.app/hash/issue/H-5351/reuse-parts-between-compilation-units - let mut heap = Heap::uninitialized(); - - if matches!(query, EntityQuery::Query { .. }) { - // The heap is going to be used in the compilation of the query and therefore needs to be - // primed. - // Doing this in a separate step allows us to be allocation free when not using HashQL - // queries. - heap.prime(); - } - - let filter = query.compile(&heap, CompilationOptions { interactive })?; - - let params = options - .into_params(filter, api_config) - .attach(hash_status::StatusCode::InvalidArgument) - .map_err(report_to_response)?; - - let response = store - .query_entities(actor_id, params) - .await - .map(Json) - .map_err(report_to_response); - - if let Some(query_logger) = &mut query_logger { - query_logger.send().await.map_err(report_to_response)?; - } - response -} - -#[derive(Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -struct QueryEntitySubgraphResponse<'r> { - subgraph: Subgraph, - #[serde(borrow)] - 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>, -} - -#[utoipa::path( - post, - path = "/entities/query/subgraph", - request_body = QueryEntitySubgraphRequest, - tag = "Entity", - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - ("Interactive" = Option, Header, description = "Whether the query is interactive"), - ("after" = Option, Query, description = "The cursor to start reading from"), - ("limit" = Option, Query, description = "The maximum number of entities to read"), - ), - responses( - ( - status = 200, - content_type = "application/json", - body = QueryEntitySubgraphResponse, - description = "A subgraph rooted at entities that satisfy the given query, each resolved to the requested depth.", - ), - (status = 422, content_type = "text/plain", description = "Provided query is invalid"), - (status = 500, description = "Store error occurred"), - ) -)] -async fn query_entity_subgraph( - AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - InteractiveHeader(interactive): InteractiveHeader, - store_pool: Extension>, - temporal_client: Extension>>, - Extension(api_config): Extension, - mut query_logger: Option>, - Json(request): Json, -) -> Result>, BoxedResponse> -where - S: StorePool + Send + Sync, -{ - if let Some(query_logger) = &mut query_logger { - query_logger.capture(actor_id, OpenApiQuery::GetEntitySubgraph(&request)); - } - - let store = store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)?; - - let request = QueryEntitySubgraphRequest::deserialize(&request) - .map_err(Report::from) - .map_err(report_to_response)?; - let (query, options, traversal) = request.into_parts(); - - // TODO: https://linear.app/hash/issue/H-5351/reuse-parts-between-compilation-units - let mut heap = Heap::uninitialized(); - - if matches!(query, EntityQuery::Query { .. }) { - // The heap is going to be used in the compilation of the query and therefore needs to be - // primed. - // Doing this in a separate step allows us to be allocation free when not using HashQL - // queries. - heap.prime(); - } - - let filter = query.compile(&heap, CompilationOptions { interactive })?; - - let params = options - .into_traversal_params(filter, traversal, api_config) - .attach(hash_status::StatusCode::InvalidArgument) - .map_err(report_to_response)?; - - let response = store - .query_entity_subgraph(actor_id, params) - .await - .map(|response| { - 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, - }) - }) - .map_err(report_to_response); - if let Some(query_logger) = &mut query_logger { - query_logger.send().await.map_err(report_to_response)?; - } - response -} - -#[utoipa::path( - post, - path = "/entities/query/count", - request_body = CountEntitiesParams, - tag = "Entity", - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - - ), - responses( - ( - status = 200, - content_type = "application/json", - body = usize, - ), - (status = 422, content_type = "text/plain", description = "Provided query is invalid"), - (status = 500, description = "Store error occurred"), - ) -)] -async fn count_entities( - AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - store_pool: Extension>, - temporal_client: Extension>>, - mut query_logger: Option>, - Json(request): Json, -) -> Result, BoxedResponse> -where - S: StorePool + Send + Sync, -{ - if let Some(query_logger) = &mut query_logger { - query_logger.capture(actor_id, OpenApiQuery::CountEntities(&request)); - } - - let store = store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)?; - - let response = store - .count_entities( - actor_id, - CountEntitiesParams::deserialize(&request) - .map_err(Report::from) - .map_err(report_to_response)?, - ) - .await - .map(Json) - .map_err(report_to_response); - if let Some(query_logger) = &mut query_logger { - query_logger.send().await.map_err(report_to_response)?; - } - response -} - #[utoipa::path( patch, path = "/entities", diff --git a/libs/@local/graph/api/src/rest/entity/query/mod.rs b/libs/@local/graph/api/src/rest/entity/query/mod.rs new file mode 100644 index 00000000000..31e0b654121 --- /dev/null +++ b/libs/@local/graph/api/src/rest/entity/query/mod.rs @@ -0,0 +1,259 @@ +pub(crate) mod request; + +use alloc::sync::Arc; +use std::collections::HashMap; + +use axum::Extension; +use error_stack::{Report, ResultExt as _}; +use hash_graph_store::{ + entity::{ + ClosedMultiEntityTypeMap, CountEntitiesParams, EntityPermissions, EntityQueryCursor, + EntityStore as _, QueryEntitiesResponse, + }, + entity_type::EntityTypeResolveDefinitions, + pool::StorePool, +}; +use hash_temporal_client::TemporalClient; +use serde::Deserialize as _; +use serde_json::value::RawValue as RawJsonValue; +use type_system::{ + knowledge::entity::id::EntityId, + ontology::VersionedUrl, + principal::{actor::ActorEntityUuid, actor_group::WebId}, +}; + +pub use self::request::{ + QueryEntitiesRequest, QueryEntitySubgraphError, QueryEntitySubgraphRequest, +}; +use crate::rest::{ + ApiConfig, AuthenticatedUserHeader, OpenApiQuery, QueryLogger, + json::Json, + status::{BoxedResponse, report_to_response}, + utoipa_typedef::subgraph::Subgraph, +}; + +#[utoipa::path( + post, + path = "/entities/query", + request_body = QueryEntitiesRequest, + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ("after" = Option, Query, description = "The cursor to start reading from"), + ("limit" = Option, Query, description = "The maximum number of entities to read"), + ), + responses( + ( + status = 200, + content_type = "application/json", + body = QueryEntitiesResponse, + description = "A list of entities that satisfy the given query.", + ), + (status = 422, content_type = "text/plain", description = "Provided query is invalid"), + (status = 500, description = "Store error occurred"), + ) +)] +pub(super) async fn query_entities( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + Extension(api_config): Extension, + mut query_logger: Option>, + Json(request): Json>, +) -> Result>, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + if let Some(query_logger) = &mut query_logger { + query_logger.capture(actor_id, OpenApiQuery::GetEntities(&request)); + } + + let store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + let request = QueryEntitiesRequest::deserialize(&*request) + .map_err(Report::from) + .map_err(report_to_response)?; + + let params = request + .into_params(api_config) + .attach(hash_status::StatusCode::InvalidArgument) + .map_err(report_to_response)?; + + let response = store + .query_entities(actor_id, params) + .await + .map(Json) + .map_err(report_to_response); + + if let Some(query_logger) = &mut query_logger { + query_logger.send().await.map_err(report_to_response)?; + } + response +} + +#[derive(serde::Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub(super) struct QueryEntitySubgraphResponse<'r> { + subgraph: Subgraph, + #[serde(borrow)] + 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>, +} + +#[utoipa::path( + post, + path = "/entities/query/subgraph", + request_body = QueryEntitySubgraphRequest, + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ("after" = Option, Query, description = "The cursor to start reading from"), + ("limit" = Option, Query, description = "The maximum number of entities to read"), + ), + responses( + ( + status = 200, + content_type = "application/json", + body = QueryEntitySubgraphResponse, + description = "A subgraph rooted at entities that satisfy the given query, each resolved to the requested depth.", + ), + (status = 422, content_type = "text/plain", description = "Provided query is invalid"), + (status = 500, description = "Store error occurred"), + ) +)] +pub(super) async fn query_entity_subgraph( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + Extension(api_config): Extension, + mut query_logger: Option>, + Json(request): Json, +) -> Result>, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + if let Some(query_logger) = &mut query_logger { + query_logger.capture(actor_id, OpenApiQuery::GetEntitySubgraph(&request)); + } + + let store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + let request = QueryEntitySubgraphRequest::deserialize(&request) + .map_err(Report::from) + .map_err(report_to_response)?; + + let params = request + .into_traversal_params(api_config) + .attach(hash_status::StatusCode::InvalidArgument) + .map_err(report_to_response)?; + + let response = store + .query_entity_subgraph(actor_id, params) + .await + .map(|response| { + 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, + }) + }) + .map_err(report_to_response); + if let Some(query_logger) = &mut query_logger { + query_logger.send().await.map_err(report_to_response)?; + } + response +} + +#[utoipa::path( + post, + path = "/entities/query/count", + request_body = CountEntitiesParams, + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + + ), + responses( + ( + status = 200, + content_type = "application/json", + body = usize, + ), + (status = 422, content_type = "text/plain", description = "Provided query is invalid"), + (status = 500, description = "Store error occurred"), + ) +)] +pub(super) async fn count_entities( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + mut query_logger: Option>, + Json(request): Json, +) -> Result, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + if let Some(query_logger) = &mut query_logger { + query_logger.capture(actor_id, OpenApiQuery::CountEntities(&request)); + } + + let store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + let response = store + .count_entities( + actor_id, + CountEntitiesParams::deserialize(&request) + .map_err(Report::from) + .map_err(report_to_response)?, + ) + .await + .map(Json) + .map_err(report_to_response); + if let Some(query_logger) = &mut query_logger { + query_logger.send().await.map_err(report_to_response)?; + } + 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 new file mode 100644 index 00000000000..dc256dd8f00 --- /dev/null +++ b/libs/@local/graph/api/src/rest/entity/query/request.rs @@ -0,0 +1,836 @@ +use error_stack::{Report, ResultExt as _}; +use hash_graph_store::{ + entity::{ + EntityQueryCursor, EntityQueryPath, EntityQuerySorting, EntityQuerySortingRecord, + QueryConversion, QueryEntitiesParams, QueryEntitySubgraphParams, + }, + entity_type::IncludeEntityTypeOption, + filter::Filter, + query::Ordering, + subgraph::{ + edges::{ + EntityTraversalPath, GraphResolveDepths, MAX_TRAVERSAL_PATHS, SubgraphTraversalParams, + TraversalDepthError, TraversalPath, + }, + temporal_axes::QueryTemporalAxesUnresolved, + }, +}; +use type_system::knowledge::Entity; + +use crate::rest::{ApiConfig, LimitExceededError, resolve_limit}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, derive_more::Display)] +pub enum QueryEntitySubgraphError { + #[display("Query limit exceeded")] + Limit, + #[display("Traversal depth exceeded")] + TraversalDepth, + #[display("Resolve depth exceeded")] + ResolveDepth, +} + +impl core::error::Error for QueryEntitySubgraphError {} + +fn validate_traversal( + params: &SubgraphTraversalParams, +) -> Result<(), Report> { + match params { + SubgraphTraversalParams::Paths { traversal_paths } => { + if traversal_paths.len() > MAX_TRAVERSAL_PATHS { + return Err(Report::new(TraversalDepthError::TooManyPaths { + actual: traversal_paths.len(), + max: MAX_TRAVERSAL_PATHS, + }) + .change_context(QueryEntitySubgraphError::TraversalDepth)); + } + for path in traversal_paths { + path.validate() + .change_context(QueryEntitySubgraphError::TraversalDepth)?; + } + } + SubgraphTraversalParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + } => { + if traversal_paths.len() > MAX_TRAVERSAL_PATHS { + return Err(Report::new(TraversalDepthError::TooManyPaths { + actual: traversal_paths.len(), + max: MAX_TRAVERSAL_PATHS, + }) + .change_context(QueryEntitySubgraphError::TraversalDepth)); + } + for path in traversal_paths { + path.validate() + .change_context(QueryEntitySubgraphError::TraversalDepth)?; + } + graph_resolve_depths + .validate() + .change_context(QueryEntitySubgraphError::ResolveDepth)?; + } + } + Ok(()) +} + +#[tracing::instrument(level = "info", skip_all)] +fn generate_sorting_paths( + paths: Option>>, + temporal_axes: &QueryTemporalAxesUnresolved, +) -> Vec> { + let temporal_axes_sorting_path = match temporal_axes { + QueryTemporalAxesUnresolved::TransactionTime { .. } => &EntityQueryPath::TransactionTime, + QueryTemporalAxesUnresolved::DecisionTime { .. } => &EntityQueryPath::DecisionTime, + }; + + paths + .map_or_else( + || { + vec![ + EntityQuerySortingRecord { + path: temporal_axes_sorting_path.clone(), + ordering: Ordering::Descending, + nulls: None, + }, + EntityQuerySortingRecord { + path: EntityQueryPath::Uuid, + ordering: Ordering::Ascending, + nulls: None, + }, + EntityQuerySortingRecord { + path: EntityQueryPath::WebId, + ordering: Ordering::Ascending, + nulls: None, + }, + ] + }, + |mut paths| { + let mut has_temporal_axis = false; + let mut has_uuid = false; + let mut has_web_id = false; + + for path in &paths { + if path.path == EntityQueryPath::TransactionTime + || path.path == EntityQueryPath::DecisionTime + { + has_temporal_axis = true; + } + if path.path == EntityQueryPath::Uuid { + has_uuid = true; + } + if path.path == EntityQueryPath::WebId { + has_web_id = true; + } + } + + if !has_temporal_axis { + paths.push(EntityQuerySortingRecord { + path: temporal_axes_sorting_path.clone(), + ordering: Ordering::Descending, + nulls: None, + }); + } + if !has_uuid { + paths.push(EntityQuerySortingRecord { + path: EntityQueryPath::Uuid, + ordering: Ordering::Ascending, + nulls: None, + }); + } + if !has_web_id { + paths.push(EntityQuerySortingRecord { + path: EntityQueryPath::WebId, + ordering: Ordering::Ascending, + nulls: None, + }); + } + + paths + }, + ) + .into_iter() + .map(EntityQuerySortingRecord::into_owned) + .collect() +} + +#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] +#[expect( + clippy::struct_excessive_bools, + reason = "Parameter struct deserialized from JSON" +)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct QueryEntitiesRequest<'q, 's, 'p> { + #[serde(borrow)] + pub filter: Filter<'q, Entity>, + + pub temporal_axes: QueryTemporalAxesUnresolved, + pub include_drafts: bool, + pub limit: Option, + #[serde(borrow, default)] + pub conversions: Vec>, + #[serde(borrow)] + pub sorting_paths: Option>>, + #[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, +} + +impl<'q, 'p> QueryEntitiesRequest<'q, '_, 'p> { + /// Convert this request into [`QueryEntitiesParams`] with the given [`ApiConfig`] and resolved + /// limit. + /// + /// Does not validate that the resolved limit does not exceed [`ApiConfig::query_entity_limit`]. + pub fn into_params_unchecked( + self, + config: ApiConfig, + limit: Option, + ) -> QueryEntitiesParams<'q> + where + 'p: 'q, + { + let limit = limit.or(self.limit).unwrap_or(config.query_entity_limit); + + QueryEntitiesParams { + filter: self.filter, + sorting: EntityQuerySorting { + paths: generate_sorting_paths(self.sorting_paths, &self.temporal_axes), + cursor: self.cursor.map(EntityQueryCursor::into_owned), + }, + 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, + } + } + + /// Convert this request into [`QueryEntitiesParams`] with the given [`ApiConfig`] and resolved + /// limit. + /// + /// # Errors + /// + /// Returns [`LimitExceededError`] if the requested limit exceeds the configured maximum in + /// [`ApiConfig::query_entity_limit`]. + pub fn into_params( + self, + config: ApiConfig, + ) -> Result, Report> + where + 'p: 'q, + { + let limit = resolve_limit(self.limit, config.query_entity_limit)?; + + Ok(self.into_params_unchecked(config, Some(limit))) + } +} + +#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] +#[serde(untagged, deny_unknown_fields)] +pub enum QueryEntitySubgraphRequest<'q, 's, 'p> { + #[serde(rename_all = "camelCase")] + ResolveDepths { + traversal_paths: Vec, + graph_resolve_depths: GraphResolveDepths, + #[serde(borrow, flatten)] + request: QueryEntitiesRequest<'q, 's, 'p>, + }, + #[serde(rename_all = "camelCase")] + Paths { + traversal_paths: Vec, + #[serde(borrow, flatten)] + request: QueryEntitiesRequest<'q, 's, 'p>, + }, +} + +impl<'q, 's, 'p> QueryEntitySubgraphRequest<'q, 's, 'p> { + #[must_use] + pub fn into_parts(self) -> (QueryEntitiesRequest<'q, 's, 'p>, SubgraphTraversalParams) { + match self { + QueryEntitySubgraphRequest::Paths { + traversal_paths, + request: options, + } => (options, SubgraphTraversalParams::Paths { traversal_paths }), + QueryEntitySubgraphRequest::ResolveDepths { + traversal_paths, + graph_resolve_depths, + request: options, + } => ( + options, + SubgraphTraversalParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + }, + ), + } + } + + #[must_use] + pub fn from_parts( + request: QueryEntitiesRequest<'q, 's, 'p>, + params: SubgraphTraversalParams, + ) -> Self { + match params { + SubgraphTraversalParams::Paths { traversal_paths } => { + QueryEntitySubgraphRequest::Paths { + traversal_paths, + request, + } + } + SubgraphTraversalParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + } => QueryEntitySubgraphRequest::ResolveDepths { + traversal_paths, + graph_resolve_depths, + request, + }, + } + } + + /// Convert the request into traversal parameters. Skipping validation. + #[must_use] + pub fn into_traversal_params_unchecked(self, config: ApiConfig) -> QueryEntitySubgraphParams<'q> + where + 'p: 'q, + { + let (request, params) = self.into_parts(); + let request = request.into_params_unchecked(config, None); + + match params { + SubgraphTraversalParams::Paths { traversal_paths } => { + QueryEntitySubgraphParams::Paths { + traversal_paths, + request, + } + } + SubgraphTraversalParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + } => QueryEntitySubgraphParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + request, + }, + } + } + + /// Convert the request into traversal parameters. + /// + /// # Errors + /// + /// Returns [`QueryEntitySubgraphError`] if: + /// - The requested limit exceeds the configured maximum. + /// - The number of traversal paths exceeds [`MAX_TRAVERSAL_PATHS`]. + /// - Any traversal path exceeds the maximum edge count. + /// - Graph resolve depths exceed the allowed maximum. + pub fn into_traversal_params( + self, + config: ApiConfig, + ) -> Result, Report> + where + 'p: 'q, + { + let (request, params) = self.into_parts(); + + validate_traversal(¶ms)?; + + let request = request + .into_params(config) + .change_context(QueryEntitySubgraphError::Limit)?; + + match params { + SubgraphTraversalParams::Paths { traversal_paths } => { + Ok(QueryEntitySubgraphParams::Paths { + traversal_paths, + request, + }) + } + SubgraphTraversalParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + } => Ok(QueryEntitySubgraphParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + request, + }), + } + } +} + +#[cfg(test)] +mod tests { + use core::assert_matches; + + use serde_json::json; + + use super::*; + + /// Minimal valid temporal axes for test payloads. + fn temporal_axes() -> serde_json::Value { + json!({ + "pinned": { + "axis": "transactionTime", + "timestamp": null + }, + "variable": { + "axis": "decisionTime", + "interval": { + "start": null, + "end": null + } + } + }) + } + + /// Minimal valid request body shared across tests. + fn base_request() -> String { + json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string() + } + + #[test] + fn deserialize_minimal_entity_request() { + let payload = base_request(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitiesRequest { + include_drafts: false, + include_permissions: false, + limit: None, + sorting_paths: None, + cursor: None, + 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, + .. + }) + ); + } + + #[test] + fn deserialize_entity_request_with_all_fields() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": true, + "includePermissions": true, + "limit": 50, + "includeCount": true, + "includeWebIds": true, + "includeCreatedByIds": true, + "includeEditionCreatedByIds": true, + "includeTypeIds": true, + "includeTypeTitles": true + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitiesRequest { + include_drafts: true, + include_permissions: true, + limit: Some(50), + include_count: true, + include_web_ids: true, + include_created_by_ids: true, + include_edition_created_by_ids: true, + include_type_ids: true, + include_type_titles: true, + .. + }) + ); + } + + #[test] + fn reject_entity_request_missing_filter() { + let payload = json!({ + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("missing filter should fail") + .to_string(); + assert!(err.starts_with("missing field `filter`"), "{err}"); + } + + #[test] + fn reject_entity_request_missing_temporal_axes() { + let payload = json!({ + "filter": { "all": [] }, + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("missing temporalAxes should fail") + .to_string(); + assert!(err.starts_with("missing field `temporalAxes`"), "{err}"); + } + + #[test] + fn reject_entity_request_missing_include_drafts() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includePermissions": false + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("missing includeDrafts should fail") + .to_string(); + assert!(err.starts_with("missing field `includeDrafts`"), "{err}"); + } + + #[test] + fn reject_entity_request_missing_include_permissions() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("missing includePermissions should fail") + .to_string(); + assert!( + err.starts_with("missing field `includePermissions`"), + "{err}" + ); + } + + #[test] + fn deserialize_subgraph_paths_variant() { + let payload = json!({ + "traversalPaths": [ + { + "edges": [ + { "kind": "has-left-entity", "direction": "incoming" }, + { "kind": "has-right-entity", "direction": "outgoing" } + ] + } + ], + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitySubgraphRequest::Paths { + traversal_paths, + request: QueryEntitiesRequest { include_drafts: false, .. }, + }) if traversal_paths.len() == 1 && traversal_paths[0].edges.len() == 2 + ); + } + + #[test] + fn deserialize_subgraph_resolve_depths_variant() { + let payload = json!({ + "traversalPaths": [], + "graphResolveDepths": { + "inheritsFrom": 0, + "constrainsValuesOn": 0, + "constrainsPropertiesOn": 0, + "constrainsLinksOn": 0, + "constrainsLinkDestinationsOn": 0, + "isOfType": false + }, + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitySubgraphRequest::ResolveDepths { + traversal_paths, + graph_resolve_depths: GraphResolveDepths { + inherits_from: 0, + is_of_type: false, + .. + }, + request: QueryEntitiesRequest { include_drafts: false, .. }, + }) if traversal_paths.is_empty() + ); + } + + #[test] + fn reject_subgraph_missing_traversal_paths() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("missing traversalPaths should fail") + .to_string(); + assert!( + err.starts_with( + "data did not match any variant of untagged enum QueryEntitySubgraphRequest" + ), + "{err}" + ); + } + + #[test] + fn deserialize_filter_request_with_limit_and_count() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "limit": 100, + "includeCount": true, + "includePermissions": false + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitiesRequest { + limit: Some(100), + include_count: true, + .. + }) + ); + } + + #[test] + fn deserialize_subgraph_resolve_depths_with_traversal() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "graphResolveDepths": { + "inheritsFrom": 255, + "constrainsValuesOn": 255, + "constrainsPropertiesOn": 255, + "constrainsLinksOn": 255, + "constrainsLinkDestinationsOn": 255, + "isOfType": true + }, + "traversalPaths": [ + { + "edges": [ + { "kind": "has-left-entity", "direction": "incoming" }, + { "kind": "has-right-entity", "direction": "outgoing" } + ] + } + ], + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitySubgraphRequest::ResolveDepths { + traversal_paths, + graph_resolve_depths: GraphResolveDepths { + inherits_from: 255, + is_of_type: true, + .. + }, + request: QueryEntitiesRequest { include_permissions: false, .. }, + }) if traversal_paths.len() == 1 + ); + } + + #[test] + fn reject_resolve_depths_with_non_entity_edge() { + // If traversalPaths contains an ontology edge (e.g. "is-of-type"), it can't + // deserialize as EntityTraversalPath. The untagged enum must not silently + // fall through to the Paths variant, dropping graphResolveDepths. + let payload = json!({ + "traversalPaths": [ + { + "edges": [ + { "kind": "is-of-type" } + ] + } + ], + "graphResolveDepths": { + "inheritsFrom": 255, + "constrainsValuesOn": 255, + "constrainsPropertiesOn": 255, + "constrainsLinksOn": 255, + "constrainsLinkDestinationsOn": 255, + "isOfType": true + }, + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + let result = serde_json::from_str::>(&payload); + + match result { + Err(_) => {} // Correctly rejected + Ok(QueryEntitySubgraphRequest::ResolveDepths { .. }) => { + panic!("should not parse ontology edges as EntityTraversalPath"); + } + Ok(QueryEntitySubgraphRequest::Paths { .. }) => { + panic!("silently fell through to Paths variant, dropping graphResolveDepths"); + } + } + } + + #[test] + fn deserialize_paths_with_ontology_edge() { + // Ontology edges (like is-of-type) are valid in TraversalPath but not + // EntityTraversalPath. Without graphResolveDepths, this should parse as Paths. + let payload = json!({ + "traversalPaths": [ + { + "edges": [ + { "kind": "has-left-entity", "direction": "incoming" }, + { "kind": "is-of-type" } + ] + } + ], + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitySubgraphRequest::Paths { + traversal_paths, + .. + }) if traversal_paths.len() == 1 && traversal_paths[0].edges.len() == 2 + ); + } + + #[test] + fn reject_entity_request_unknown_field() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false, + "bogusField": 42 + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("unknown field should be rejected") + .to_string(); + assert!(err.contains("bogusField"), "{err}"); + } + + #[test] + fn reject_subgraph_unknown_field_through_flatten() { + // The subgraph enum uses `#[serde(flatten)]` on the inner request. + // Verify that `deny_unknown_fields` still catches unknown keys that + // would pass through the flattened struct boundary. + let payload = json!({ + "traversalPaths": [ + { + "edges": [ + { "kind": "has-left-entity", "direction": "incoming" } + ] + } + ], + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false, + "bogusField": 42 + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("unknown field through flatten should be rejected") + .to_string(); + // With untagged + flatten, serde reports "did not match any variant" + // because both variants reject the unknown field. + assert!( + err.contains("bogusField") || err.contains("did not match any variant"), + "{err}" + ); + } + + #[test] + fn reject_subgraph_resolve_depths_unknown_field_through_flatten() { + let payload = json!({ + "traversalPaths": [], + "graphResolveDepths": { + "inheritsFrom": 0, + "constrainsValuesOn": 0, + "constrainsPropertiesOn": 0, + "constrainsLinksOn": 0, + "constrainsLinkDestinationsOn": 0, + "isOfType": false + }, + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false, + "sneakyExtra": true + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("unknown field through flatten should be rejected") + .to_string(); + assert!( + err.contains("sneakyExtra") || err.contains("did not match any variant"), + "{err}" + ); + } + + #[test] + fn deserialize_subgraph_paths_with_traversal() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "traversalPaths": [ + { + "edges": [ + { "kind": "has-left-entity", "direction": "incoming" }, + { "kind": "has-right-entity", "direction": "outgoing" } + ] + } + ], + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitySubgraphRequest::Paths { + traversal_paths, + request: QueryEntitiesRequest { include_permissions: false, .. }, + }) if traversal_paths.len() == 1 && traversal_paths[0].edges.len() == 2 + ); + } +} diff --git a/libs/@local/graph/api/src/rest/entity_query_request.rs b/libs/@local/graph/api/src/rest/entity_query_request.rs deleted file mode 100644 index a4710c5a028..00000000000 --- a/libs/@local/graph/api/src/rest/entity_query_request.rs +++ /dev/null @@ -1,873 +0,0 @@ -//! Request types for entity queries. -//! -//! Contains the deserialization structs for both simple entity queries and subgraph requests. -//! Some design choices may look odd due to serde/OpenAPI limitations we need to work around: -//! -//! - Uses proxy structs for deserialization because `RawValue` doesn't play nice with `untagged` + -//! `deny_unknown_fields` (forces intermediate representation). -//! - Subgraph enum has 4 variants instead of nested structs because openapi-generator uses `&` -//! instead of `|` for nested `oneOf` constraints. -//! - Outer enum instead of nested enum because utoipa generates `allOf` constraints (merges all -//! fields into one type). With discriminator on the outer edge we get `oneOf` (proper union), but -//! openapi-generator can't handle nested oneOf and merges them anyway - so we flatten everything -//! - Lots of boolean fields instead of option structs for the same reason -//! -//! When changing any of these types, make sure that the OpenAPI generator types do not degenerate -//! into any of these cases. -#![expect( - dead_code, - reason = "https://linear.app/hash/issue/BE-537/hashql-remove-old-backend-wire-up-hashql-in-the-api" -)] -use alloc::borrow::Cow; -use core::{cmp, ops::Range}; - -use axum::{ - Json, - response::{Html, IntoResponse as _}, -}; -use error_stack::Report; -use hash_graph_store::{ - entity::{ - EntityQueryCursor, EntityQueryPath, EntityQuerySorting, EntityQuerySortingRecord, - QueryConversion, QueryEntitiesParams, QueryEntitySubgraphParams, - }, - entity_type::IncludeEntityTypeOption, - filter::Filter, - query::Ordering, - subgraph::{ - edges::{ - EntityTraversalPath, GraphResolveDepths, MAX_TRAVERSAL_PATHS, - ResolveDepthExceededError, SubgraphTraversalParams, SubgraphTraversalValidationError, - TraversalDepthError, TraversalPath, TraversalPathConversionError, - }, - temporal_axes::QueryTemporalAxesUnresolved, - }, -}; -use hashql_ast::error::AstDiagnosticCategory; -use hashql_core::{ - heap::Heap, - span::{SpanId, SpanTable}, -}; -use hashql_diagnostics::{ - DiagnosticIssues, Failure, Severity, - category::{DiagnosticCategory, canonical_category_id}, - diagnostic::render::{Format, RenderOptions}, - source::{DiagnosticSpan, Source, Sources}, -}; -use hashql_eval::error::EvalDiagnosticCategory; -use hashql_hir::error::HirDiagnosticCategory; -use hashql_syntax_jexpr::{error::JExprDiagnosticCategory, span::Span}; -use http::StatusCode; -use serde::Deserialize; -use serde_json::value::RawValue as RawJsonValue; -use type_system::knowledge::Entity; -use utoipa::ToSchema; - -use super::{ApiConfig, LimitExceededError, resolve_limit, status::BoxedResponse}; - -#[tracing::instrument(level = "info", skip_all)] -fn generate_sorting_paths( - paths: Option>>, - temporal_axes: &QueryTemporalAxesUnresolved, -) -> Vec> { - let temporal_axes_sorting_path = match temporal_axes { - QueryTemporalAxesUnresolved::TransactionTime { .. } => &EntityQueryPath::TransactionTime, - QueryTemporalAxesUnresolved::DecisionTime { .. } => &EntityQueryPath::DecisionTime, - }; - - paths - .map_or_else( - || { - vec![ - EntityQuerySortingRecord { - path: temporal_axes_sorting_path.clone(), - ordering: Ordering::Descending, - nulls: None, - }, - EntityQuerySortingRecord { - path: EntityQueryPath::Uuid, - ordering: Ordering::Ascending, - nulls: None, - }, - EntityQuerySortingRecord { - path: EntityQueryPath::WebId, - ordering: Ordering::Ascending, - nulls: None, - }, - ] - }, - |mut paths| { - let mut has_temporal_axis = false; - let mut has_uuid = false; - let mut has_web_id = false; - - for path in &paths { - if path.path == EntityQueryPath::TransactionTime - || path.path == EntityQueryPath::DecisionTime - { - has_temporal_axis = true; - } - if path.path == EntityQueryPath::Uuid { - has_uuid = true; - } - if path.path == EntityQueryPath::WebId { - has_web_id = true; - } - } - - if !has_temporal_axis { - paths.push(EntityQuerySortingRecord { - path: temporal_axes_sorting_path.clone(), - ordering: Ordering::Descending, - nulls: None, - }); - } - if !has_uuid { - paths.push(EntityQuerySortingRecord { - path: EntityQueryPath::Uuid, - ordering: Ordering::Ascending, - nulls: None, - }); - } - if !has_web_id { - paths.push(EntityQuerySortingRecord { - path: EntityQueryPath::WebId, - ordering: Ordering::Ascending, - nulls: None, - }); - } - - paths - }, - ) - .into_iter() - .map(EntityQuerySortingRecord::into_owned) - .collect() -} - -/// Internal deserialization proxy for `QueryEntitiesRequest`. -/// -/// This struct is necessary because [`RawJsonValue`] cannot be used directly with -/// `#[serde(untagged, deny_unknown_fields)]` - these attributes force deserialization into an -/// intermediate representation, which cannot deserialize into a [`RawJsonValue`] as it materializes -/// the content. -/// -/// 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` - #[serde(borrow)] - filter: Option>, - // `QueryEntitiesQuery::Query`, - #[serde(borrow)] - query: Option<&'q RawJsonValue>, - - // `QueryEntitiesRequest` - temporal_axes: QueryTemporalAxesUnresolved, - include_drafts: bool, - limit: Option, - #[serde(borrow, default)] - conversions: Vec>, - #[serde(borrow)] - sorting_paths: Option>>, - #[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>, - graph_resolve_depths: Option, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -pub(crate) struct CompilationOptions { - pub interactive: bool, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -enum HashQLDiagnosticCategory { - JExpr(JExprDiagnosticCategory), - Ast(AstDiagnosticCategory), - Hir(HirDiagnosticCategory), - Eval(EvalDiagnosticCategory), -} - -impl serde::Serialize for HashQLDiagnosticCategory { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.collect_str(&canonical_category_id(self)) - } -} - -impl DiagnosticCategory for HashQLDiagnosticCategory { - fn id(&self) -> Cow<'_, str> { - Cow::Borrowed("hashql") - } - - fn name(&self) -> Cow<'_, str> { - Cow::Borrowed("HashQL") - } - - fn subcategory(&self) -> Option<&dyn DiagnosticCategory> { - match self { - Self::JExpr(jexpr) => Some(jexpr), - Self::Ast(ast) => Some(ast), - Self::Hir(hir) => Some(hir), - Self::Eval(eval) => Some(eval), - } - } -} - -#[derive(Debug, serde::Serialize)] -struct ResolvedSpan { - pub range: Range, - pub pointer: Option, -} - -fn resolve_span(id: SpanId, mut spans: &SpanTable) -> Option { - let absolute = DiagnosticSpan::absolute(&id, &mut spans)?; - let mut pointer = spans.get(id)?.pointer.as_ref().map(ToString::to_string); - - for ancestor in spans.ancestors(id) { - let Some(ancestor) = spans.get(ancestor) else { - continue; - }; - - if pointer.is_none() - && let Some(ancestor_pointer) = &ancestor.pointer - { - pointer = Some(ancestor_pointer.to_string()); - } - } - - Some(ResolvedSpan { - range: absolute.range().into(), - pointer, - }) -} - -fn issues_to_response( - issues: DiagnosticIssues, - severity: Severity, - source: &str, - mut spans: &SpanTable, - options: CompilationOptions, -) -> BoxedResponse { - let status_code = match severity { - Severity::Bug | Severity::Fatal => StatusCode::INTERNAL_SERVER_ERROR, - Severity::Error => StatusCode::BAD_REQUEST, - Severity::Warning | Severity::Note | Severity::Debug => StatusCode::CONFLICT, - }; - - let mut sources = Sources::new(); - sources.push(Source::new(source)); - - let mut response = if options.interactive { - let output = issues.render(RenderOptions::new(Format::Html, &sources), &mut spans); - - Html(output).into_response() - } else { - let diagnostics: Vec<_> = issues - .into_iter() - .map(|diagnostic| diagnostic.map_spans(|span| resolve_span(span, spans))) - .collect(); - - Json(diagnostics).into_response() - }; - - *response.status_mut() = status_code; - response.into() -} - -fn failure_to_response( - failure: Failure, - source: &str, - spans: &SpanTable, - options: CompilationOptions, -) -> BoxedResponse { - // Find the highest diagnostic level - let severity = cmp::max( - failure - .secondary - .iter() - .map(|diagnostic| diagnostic.severity) - .max() - .unwrap_or(Severity::Debug), - failure.primary.severity.into(), - ); - - issues_to_response(failure.into_issues(), severity, source, spans, options) -} - -#[derive(Debug, Clone)] -#[expect(clippy::large_enum_variant)] -pub enum EntityQuery<'q> { - Filter { filter: Filter<'q, Entity> }, - Query { query: &'q RawJsonValue }, -} - -impl<'q> EntityQuery<'q> { - /// Compiles a query into an executable entity filter. - /// - /// Transforms the query representation into a [`Filter`] that can be executed - /// against the entity store. For already-compiled filter queries, this returns - /// the filter directly. For raw HashQL queries, it parses and compiles them using - /// the provided `heap` arena allocator. - /// - /// # Errors - /// - /// Returns an error if the HashQL query cannot be compiled. - pub(crate) fn compile( - self, - _: &'q Heap, - _: CompilationOptions, - ) -> Result, BoxedResponse> { - match self { - EntityQuery::Filter { filter } => Ok(filter), - EntityQuery::Query { query: _ } => { - let response = (StatusCode::NOT_IMPLEMENTED, "https://linear.app/hash/issue/BE-537/hashql-remove-old-backend-wire-up-hashql-in-the-api").into_response(); - Err(response.into()) - } - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, derive_more::Display)] -pub enum EntityQueryOptionsError { - #[display( - "Field '{field}' is only valid in subgraph requests. Use the subgraph endpoint instead." - )] - InvalidFieldForEntityQuery { field: &'static str }, - #[display( - "Field '{field}' is only valid in entity and subgraph requests. Use the entity endpoint \ - instead." - )] - InvalidFieldForEntityOptions { field: &'static str }, -} - -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, - pub limit: Option, - #[serde(borrow, default)] - pub conversions: Vec>, - #[serde(borrow)] - pub sorting_paths: Option>>, - #[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, -} - -impl<'q, 's, 'p> TryFrom> for EntityQueryOptions<'s, 'p> { - type Error = EntityQueryOptionsError; - - fn try_from(value: FlatQueryEntitiesRequestData<'q, 's, 'p>) -> Result { - let FlatQueryEntitiesRequestData { - filter, - query, - temporal_axes, - include_drafts, - limit, - 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, - } = value; - - if filter.is_some() { - return Err(EntityQueryOptionsError::InvalidFieldForEntityOptions { field: "filter" }); - } - - if query.is_some() { - return Err(EntityQueryOptionsError::InvalidFieldForEntityOptions { field: "query" }); - } - - if graph_resolve_depths.is_some() { - return Err(EntityQueryOptionsError::InvalidFieldForEntityQuery { - field: "graphResolveDepths", - }); - } - - if traversal_paths.is_some() { - return Err(EntityQueryOptionsError::InvalidFieldForEntityQuery { - field: "traversalPaths", - }); - } - - Ok(Self { - temporal_axes, - include_drafts, - limit, - 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, - }) - } -} - -impl<'p> EntityQueryOptions<'_, 'p> { - /// # Errors - /// - /// Returns [`LimitExceededError`] if the requested limit exceeds the configured maximum in - /// [`ApiConfig::query_entity_limit`]. - pub fn into_params<'f>( - self, - filter: Filter<'f, Entity>, - config: ApiConfig, - ) -> Result, Report> - where - 'p: 'f, - { - let limit = resolve_limit(self.limit, config.query_entity_limit)?; - - Ok(QueryEntitiesParams { - filter, - sorting: EntityQuerySorting { - paths: generate_sorting_paths(self.sorting_paths, &self.temporal_axes), - cursor: self.cursor.map(EntityQueryCursor::into_owned), - }, - 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, - }) - } - - /// # Errors - /// - /// Returns [`LimitExceededError`] if the requested limit exceeds the configured maximum in - /// [`ApiConfig::query_entity_limit`]. - pub fn into_traversal_params<'q>( - self, - filter: Filter<'q, Entity>, - traversal: SubgraphTraversalParams, - config: ApiConfig, - ) -> Result, Report> - where - 'p: 'q, - { - match traversal { - SubgraphTraversalParams::Paths { traversal_paths } => { - Ok(QueryEntitySubgraphParams::Paths { - traversal_paths, - request: self.into_params(filter, config)?, - }) - } - SubgraphTraversalParams::ResolveDepths { - traversal_paths, - graph_resolve_depths, - } => Ok(QueryEntitySubgraphParams::ResolveDepths { - traversal_paths, - graph_resolve_depths, - request: self.into_params(filter, config)?, - }), - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, derive_more::Display, derive_more::From)] -enum QueryEntitiesRequestError { - #[from] - RequestOptions(EntityQueryOptionsError), - #[display("Missing required query parameter. Provide either 'filter' or 'query'.")] - MissingQueryParameter, - #[display("Conflicting query parameters. Provide either 'filter' or 'query', not both.")] - ConflictingQueryParameters, -} - -impl core::error::Error for QueryEntitiesRequestError {} - -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde( - untagged, - try_from = "FlatQueryEntitiesRequestData", - deny_unknown_fields -)] -#[expect(clippy::large_enum_variant)] -pub enum QueryEntitiesRequest<'q, 's, 'p> { - #[serde(rename_all = "camelCase")] - Query { - #[serde(borrow)] - #[schema(value_type = utoipa::openapi::schema::Value)] - query: &'q RawJsonValue, - #[serde(borrow, flatten)] - options: EntityQueryOptions<'s, 'p>, - }, - #[serde(rename_all = "camelCase")] - Filter { - #[serde(borrow)] - filter: Filter<'q, Entity>, - #[serde(borrow, flatten)] - options: EntityQueryOptions<'s, 'p>, - }, -} - -impl<'q, 's, 'p> TryFrom> - for QueryEntitiesRequest<'q, 's, 'p> -{ - type Error = QueryEntitiesRequestError; - - fn try_from(mut value: FlatQueryEntitiesRequestData<'q, 's, 'p>) -> Result { - let filter = value.filter.take(); - let query = value.query.take(); - - match (filter, query) { - (None, None) => Err(QueryEntitiesRequestError::MissingQueryParameter), - (Some(_), Some(_)) => Err(QueryEntitiesRequestError::ConflictingQueryParameters), - (Some(filter), None) => Ok(Self::Filter { - filter, - options: value.try_into()?, - }), - (None, Some(query)) => Ok(Self::Query { - query, - options: value.try_into()?, - }), - } - } -} - -impl<'q, 's, 'p> QueryEntitiesRequest<'q, 's, 'p> { - #[must_use] - pub fn from_parts(query: EntityQuery<'q>, options: EntityQueryOptions<'s, 'p>) -> Self { - match query { - EntityQuery::Filter { filter } => Self::Filter { filter, options }, - EntityQuery::Query { query } => Self::Query { query, options }, - } - } - - #[must_use] - pub fn into_parts(self) -> (EntityQuery<'q>, EntityQueryOptions<'s, 'p>) { - match self { - QueryEntitiesRequest::Query { query, options } => { - (EntityQuery::Query { query }, options) - } - QueryEntitiesRequest::Filter { filter, options } => { - (EntityQuery::Filter { filter }, options) - } - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, derive_more::Display, derive_more::From)] -enum QueryEntitySubgraphRequestError { - #[from] - QueryEntityRequest(QueryEntitiesRequestError), - #[from] - UnsupportedGraphTraversalPath(TraversalPathConversionError), - #[display( - "Subgraph request missing traversal parameters. Specify either 'traversalPaths` and \ - optionally `graphResolveDepths'." - )] - MissingSubgraphTraversal, - #[from] - TraversalValidation(SubgraphTraversalValidationError), -} - -impl core::error::Error for QueryEntitySubgraphRequestError {} - -impl From for QueryEntitySubgraphRequestError { - fn from(err: TraversalDepthError) -> Self { - Self::TraversalValidation(err.into()) - } -} - -impl From for QueryEntitySubgraphRequestError { - fn from(err: ResolveDepthExceededError) -> Self { - Self::TraversalValidation(err.into()) - } -} - -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde( - untagged, - try_from = "FlatQueryEntitiesRequestData", - deny_unknown_fields -)] -pub enum QueryEntitySubgraphRequest<'q, 's, 'p> { - #[serde(rename_all = "camelCase")] - ResolveDepthsWithQuery { - #[serde(borrow)] - #[schema(value_type = utoipa::openapi::schema::Value)] - query: &'q RawJsonValue, - traversal_paths: Vec, - graph_resolve_depths: GraphResolveDepths, - #[serde(borrow, flatten)] - options: EntityQueryOptions<'s, 'p>, - }, - #[serde(rename_all = "camelCase")] - ResolveDepthsWithFilter { - #[serde(borrow)] - filter: Filter<'q, Entity>, - traversal_paths: Vec, - graph_resolve_depths: GraphResolveDepths, - #[serde(borrow, flatten)] - options: EntityQueryOptions<'s, 'p>, - }, - #[serde(rename_all = "camelCase")] - PathsWithQuery { - #[serde(borrow)] - #[schema(value_type = utoipa::openapi::schema::Value)] - query: &'q RawJsonValue, - traversal_paths: Vec, - #[serde(borrow, flatten)] - options: EntityQueryOptions<'s, 'p>, - }, - #[serde(rename_all = "camelCase")] - PathsWithFilter { - #[serde(borrow)] - filter: Filter<'q, Entity>, - traversal_paths: Vec, - #[serde(borrow, flatten)] - options: EntityQueryOptions<'s, 'p>, - }, -} - -impl<'q, 's, 'p> TryFrom> - for QueryEntitySubgraphRequest<'q, 's, 'p> -{ - type Error = QueryEntitySubgraphRequestError; - - fn try_from(mut value: FlatQueryEntitiesRequestData<'q, 's, 'p>) -> Result { - let graph_resolve_depths = value.graph_resolve_depths.take(); - let traversal_paths = value - .traversal_paths - .take() - .ok_or(QueryEntitySubgraphRequestError::MissingSubgraphTraversal)?; - - if traversal_paths.len() > MAX_TRAVERSAL_PATHS { - return Err(TraversalDepthError::TooManyPaths { - actual: traversal_paths.len(), - max: MAX_TRAVERSAL_PATHS, - } - .into()); - } - - let request = value.try_into()?; - - match graph_resolve_depths { - None => { - for path in &traversal_paths { - path.validate()?; - } - match request { - QueryEntitiesRequest::Filter { filter, options } => { - Ok(QueryEntitySubgraphRequest::PathsWithFilter { - traversal_paths, - filter, - options, - }) - } - QueryEntitiesRequest::Query { query, options } => { - Ok(QueryEntitySubgraphRequest::PathsWithQuery { - traversal_paths, - query, - options, - }) - } - } - } - Some(graph_resolve_depths) => { - let entity_paths: Vec = traversal_paths - .into_iter() - .map(EntityTraversalPath::try_from) - .collect::>()?; - for path in &entity_paths { - path.validate()?; - } - graph_resolve_depths.validate()?; - match request { - QueryEntitiesRequest::Filter { filter, options } => { - Ok(QueryEntitySubgraphRequest::ResolveDepthsWithFilter { - traversal_paths: entity_paths, - graph_resolve_depths, - filter, - options, - }) - } - QueryEntitiesRequest::Query { query, options } => { - Ok(QueryEntitySubgraphRequest::ResolveDepthsWithQuery { - traversal_paths: entity_paths, - graph_resolve_depths, - query, - options, - }) - } - } - } - } - } -} - -impl<'q, 's, 'p> QueryEntitySubgraphRequest<'q, 's, 'p> { - #[must_use] - pub fn from_parts( - query: EntityQuery<'q>, - options: EntityQueryOptions<'s, 'p>, - traversal_params: SubgraphTraversalParams, - ) -> Self { - match (query, traversal_params) { - ( - EntityQuery::Filter { filter }, - SubgraphTraversalParams::Paths { traversal_paths }, - ) => Self::PathsWithFilter { - filter, - options, - traversal_paths, - }, - (EntityQuery::Query { query }, SubgraphTraversalParams::Paths { traversal_paths }) => { - Self::PathsWithQuery { - query, - traversal_paths, - options, - } - } - ( - EntityQuery::Filter { filter }, - SubgraphTraversalParams::ResolveDepths { - traversal_paths, - graph_resolve_depths, - }, - ) => Self::ResolveDepthsWithFilter { - filter, - options, - traversal_paths, - graph_resolve_depths, - }, - ( - EntityQuery::Query { query }, - SubgraphTraversalParams::ResolveDepths { - traversal_paths, - graph_resolve_depths, - }, - ) => Self::ResolveDepthsWithQuery { - query, - options, - traversal_paths, - graph_resolve_depths, - }, - } - } - - #[must_use] - pub fn into_parts( - self, - ) -> ( - EntityQuery<'q>, - EntityQueryOptions<'s, 'p>, - SubgraphTraversalParams, - ) { - match self { - QueryEntitySubgraphRequest::PathsWithQuery { - query, - traversal_paths, - options, - } => ( - EntityQuery::Query { query }, - options, - SubgraphTraversalParams::Paths { traversal_paths }, - ), - QueryEntitySubgraphRequest::PathsWithFilter { - filter, - traversal_paths, - options, - } => ( - EntityQuery::Filter { filter }, - options, - SubgraphTraversalParams::Paths { traversal_paths }, - ), - QueryEntitySubgraphRequest::ResolveDepthsWithQuery { - query, - traversal_paths, - graph_resolve_depths, - options, - } => ( - EntityQuery::Query { query }, - options, - SubgraphTraversalParams::ResolveDepths { - traversal_paths, - graph_resolve_depths, - }, - ), - QueryEntitySubgraphRequest::ResolveDepthsWithFilter { - filter, - traversal_paths, - graph_resolve_depths, - options, - } => ( - EntityQuery::Filter { filter }, - options, - SubgraphTraversalParams::ResolveDepths { - traversal_paths, - graph_resolve_depths, - }, - ), - } - } -} diff --git a/libs/@local/graph/api/src/rest/mod.rs b/libs/@local/graph/api/src/rest/mod.rs index e776ccb6fe7..26deb299e9e 100644 --- a/libs/@local/graph/api/src/rest/mod.rs +++ b/libs/@local/graph/api/src/rest/mod.rs @@ -16,7 +16,6 @@ pub mod admin; pub mod http_tracing_layer; pub mod jwt; -mod entity_query_request; mod json; mod utoipa_typedef; use alloc::{borrow::Cow, sync::Arc}; diff --git a/tests/graph/benches/manual_queries/entity_queries/mod.rs b/tests/graph/benches/manual_queries/entity_queries/mod.rs index ff0e4d76963..448497f7762 100644 --- a/tests/graph/benches/manual_queries/entity_queries/mod.rs +++ b/tests/graph/benches/manual_queries/entity_queries/mod.rs @@ -6,8 +6,8 @@ use criterion_macro::criterion; use either::Either; use error_stack::Report; use hash_graph_api::rest::{ - self, ApiConfig, - entity::{EntityQueryOptions, QueryEntitiesRequest, QueryEntitySubgraphRequest}, + ApiConfig, + entity::query::{QueryEntitiesRequest, QueryEntitySubgraphRequest}, }; use hash_graph_postgres_store::{ Environment, load_env, @@ -142,13 +142,11 @@ impl QueryEntitiesQuery<'_, '_, '_> { 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(); - let actor_id = iter::once(self.actor_id) .chain(mem::take(&mut self.settings.parameters.actor_id)) .sorted_by_key(|actor_id| Uuid::from(*actor_id)) .dedup(); - let limit = iter::once(options.limit) + let limit = iter::once(self.request.limit) .chain( mem::take(&mut self.settings.parameters.limit) .into_iter() @@ -156,7 +154,7 @@ impl QueryEntitiesQuery<'_, '_, '_> { ) .sorted() .dedup(); - let include_count = iter::once(options.include_count) + let include_count = iter::once(self.request.include_count) .chain(mem::take(&mut self.settings.parameters.include_count)) .sorted() .dedup(); @@ -175,14 +173,11 @@ impl QueryEntitiesQuery<'_, '_, '_> { ( Self { actor_id, - request: QueryEntitiesRequest::from_parts( - query.clone(), - EntityQueryOptions { - limit, - include_count, - ..options.clone() - }, - ), + request: QueryEntitiesRequest { + limit, + include_count, + ..self.request.clone() + }, settings: self.settings.clone(), }, parameters.join(","), @@ -252,13 +247,13 @@ impl QueryEntitySubgraphQuery<'_, '_, '_> { 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(); + let (request, traversal_params) = self.request.clone().into_parts(); let actor_id = iter::once(self.actor_id) .chain(mem::take(&mut self.settings.parameters.actor_id)) .sorted_by_key(|actor_id| Uuid::from(*actor_id)) .dedup(); - let limit = iter::once(options.limit) + let limit = iter::once(request.limit) .chain( mem::take(&mut self.settings.parameters.limit) .into_iter() @@ -266,7 +261,7 @@ impl QueryEntitySubgraphQuery<'_, '_, '_> { ) .sorted() .dedup(); - let include_count = iter::once(options.include_count) + let include_count = iter::once(request.include_count) .chain(mem::take(&mut self.settings.parameters.include_count)) .sorted() .dedup(); @@ -292,11 +287,10 @@ impl QueryEntitySubgraphQuery<'_, '_, '_> { Self { actor_id, request: QueryEntitySubgraphRequest::from_parts( - query.clone(), - EntityQueryOptions { + QueryEntitiesRequest { limit, include_count, - ..options.clone() + ..request.clone() }, traversal_params, ), @@ -342,33 +336,19 @@ where match request { GraphQuery::QueryEntities(request) => { - let (query, options) = request.request.into_parts(); - let rest::entity::EntityQuery::Filter { filter } = query else { - panic!("unsupported query type") - }; - let _response = store .query_entities( request.actor_id, - options - .into_params(filter, config) - .expect("limit should not exceed configured maximum"), + request.request.into_params_unchecked(config, None), ) .await .expect("failed to read entities from store"); } GraphQuery::QueryEntitySubgraph(request) => { - let (query, options, traversal) = request.request.into_parts(); - let rest::entity::EntityQuery::Filter { filter } = query else { - panic!("unsupported query type") - }; - let _response = store .query_entity_subgraph( request.actor_id, - options - .into_traversal_params(filter, traversal, config) - .expect("limit should not exceed configured maximum"), + request.request.into_traversal_params_unchecked(config), ) .await .expect("failed to read entity subgraph from store");