From a971f9a891b52f7b86acf05ae46bc45fed83d4c8 Mon Sep 17 00:00:00 2001 From: Tim Diekmann <21277928+TimDiekmann@users.noreply.github.com> Date: Fri, 6 Dec 2024 02:56:58 +0100 Subject: [PATCH] Overhaul return behavior for entity validation --- Cargo.lock | 2 + .../rust/src/schema/entity_type/closed.rs | 14 + .../rust/src/schema/entity_type/mod.rs | 7 +- .../type-system/rust/src/schema/mod.rs | 1 + libs/@local/graph/api/Cargo.toml | 1 + .../api/openapi/models/multi_report.json | 9 + .../graph/api/openapi/models/report.json | 10 + .../openapi/models/report_context_info.json | 23 ++ libs/@local/graph/api/openapi/openapi.json | 360 +++++++++++++++++- libs/@local/graph/api/src/rest/entity.rs | 37 +- .../src/rest/json_schemas/multi_report.json | 9 + .../api/src/rest/json_schemas/report.json | 10 + .../json_schemas/report_context_info.json | 23 ++ libs/@local/graph/api/src/rest/mod.rs | 2 + libs/@local/graph/api/src/rest/status.rs | 36 +- .../src/snapshot/entity/batch.rs | 48 ++- .../store/postgres/knowledge/entity/mod.rs | 188 +++++---- .../postgres-store/src/store/validation.rs | 76 +--- .../@local/graph/sdk/typescript/src/entity.ts | 5 +- libs/@local/graph/store/Cargo.toml | 2 +- libs/@local/graph/store/src/entity/mod.rs | 8 + libs/@local/graph/store/src/entity/store.rs | 6 +- .../store/src/entity/validation_report.rs | 208 ++++++++++ libs/@local/graph/type-fetcher/src/store.rs | 8 +- .../rust/src/knowledge/property/visitor.rs | 22 +- .../graph/types/rust/src/ontology/mod.rs | 39 +- libs/@local/graph/validation/Cargo.toml | 19 +- .../graph/validation/src/entity_type.rs | 333 ++++++++-------- libs/@local/graph/validation/src/lib.rs | 45 +-- libs/@local/graph/validation/src/property.rs | 14 +- libs/chonky/README.md | 4 +- tests/graph/integration/postgres/lib.rs | 8 +- 32 files changed, 1085 insertions(+), 492 deletions(-) create mode 100644 libs/@local/graph/api/openapi/models/multi_report.json create mode 100644 libs/@local/graph/api/openapi/models/report.json create mode 100644 libs/@local/graph/api/openapi/models/report_context_info.json create mode 100644 libs/@local/graph/api/src/rest/json_schemas/multi_report.json create mode 100644 libs/@local/graph/api/src/rest/json_schemas/report.json create mode 100644 libs/@local/graph/api/src/rest/json_schemas/report_context_info.json create mode 100644 libs/@local/graph/store/src/entity/validation_report.rs diff --git a/Cargo.lock b/Cargo.lock index e93e0bf9218..6aa237e7e85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2833,6 +2833,7 @@ dependencies = [ "hash-graph-type-defs", "hash-graph-type-fetcher", "hash-graph-types", + "hash-graph-validation", "hash-status", "hash-temporal-client", "http 1.2.0", @@ -3120,6 +3121,7 @@ dependencies = [ name = "hash-graph-validation" version = "0.0.0" dependencies = [ + "derive_more 1.0.0", "error-stack", "futures", "hash-graph-store", diff --git a/libs/@blockprotocol/type-system/rust/src/schema/entity_type/closed.rs b/libs/@blockprotocol/type-system/rust/src/schema/entity_type/closed.rs index 433a167e967..35487ce68d0 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/entity_type/closed.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/entity_type/closed.rs @@ -35,6 +35,16 @@ pub struct ClosedEntityTypeMetadata { pub inverse: InverseEntityTypeMetadata, } +impl ClosedEntityTypeMetadata { + #[must_use] + pub fn is_link(&self) -> bool { + self.all_of.iter().any(|entity_type| { + entity_type.id.base_url.as_str() + == "https://blockprotocol.org/@blockprotocol/types/entity-type/link/" + }) + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -160,6 +170,10 @@ impl ClosedMultiEntityType { } } + pub fn is_link(&self) -> bool { + self.all_of.iter().any(ClosedEntityTypeMetadata::is_link) + } + /// Creates a closed entity type from multiple closed entity types. /// /// This results in a closed entity type which is used for entities with multiple types. diff --git a/libs/@blockprotocol/type-system/rust/src/schema/entity_type/mod.rs b/libs/@blockprotocol/type-system/rust/src/schema/entity_type/mod.rs index b702f5883cf..6a66b062449 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/entity_type/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/entity_type/mod.rs @@ -1,6 +1,7 @@ pub use self::{ closed::{ ClosedEntityType, ClosedEntityTypeMetadata, ClosedMultiEntityType, EntityTypeResolveData, + ResolveClosedEntityTypeError, }, constraints::EntityConstraints, reference::EntityTypeReference, @@ -81,12 +82,6 @@ pub struct EntityTypeDisplayMetadata { pub icon: Option, } -impl EntityTypeDisplayMetadata { - pub const fn is_empty(&self) -> bool { - self.label_property.is_none() && self.icon.is_none() - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/libs/@blockprotocol/type-system/rust/src/schema/mod.rs b/libs/@blockprotocol/type-system/rust/src/schema/mod.rs index ef5f1d8d29d..2a8197d1a43 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/mod.rs @@ -38,6 +38,7 @@ pub use self::{ EntityType, EntityTypeReference, EntityTypeResolveData, EntityTypeSchemaMetadata, EntityTypeToEntityTypeEdge, EntityTypeToPropertyTypeEdge, EntityTypeValidationError, EntityTypeValidator, InverseEntityTypeMetadata, PartialEntityType, + ResolveClosedEntityTypeError, }, identifier::{DataTypeUuid, EntityTypeUuid, OntologyTypeUuid, PropertyTypeUuid}, object::{ diff --git a/libs/@local/graph/api/Cargo.toml b/libs/@local/graph/api/Cargo.toml index 8cf9f625f1f..7fd0c3d7b00 100644 --- a/libs/@local/graph/api/Cargo.toml +++ b/libs/@local/graph/api/Cargo.toml @@ -36,6 +36,7 @@ harpc-types = { workspace = true } hash-graph-store = { workspace = true, features = ["utoipa"] } hash-graph-temporal-versioning = { workspace = true } hash-graph-type-defs = { workspace = true } +hash-graph-validation = { workspace = true } hash-status = { workspace = true } type-system = { workspace = true, features = ["utoipa"] } diff --git a/libs/@local/graph/api/openapi/models/multi_report.json b/libs/@local/graph/api/openapi/models/multi_report.json new file mode 100644 index 00000000000..72c5757cb28 --- /dev/null +++ b/libs/@local/graph/api/openapi/models/multi_report.json @@ -0,0 +1,9 @@ +{ + "title": "MultiReport", + "type": "array", + "description": "An error-stack Report object which may contain multiple errors", + "items": { + "$ref": "./report_context_info.json" + }, + "minItems": 1 +} diff --git a/libs/@local/graph/api/openapi/models/report.json b/libs/@local/graph/api/openapi/models/report.json new file mode 100644 index 00000000000..bee8ba0b57a --- /dev/null +++ b/libs/@local/graph/api/openapi/models/report.json @@ -0,0 +1,10 @@ +{ + "title": "Report", + "type": "array", + "description": "An error-stack Report object which may contains exactly one error", + "items": { + "$ref": "./report_context_info.json" + }, + "minItems": 1, + "maxItems": 1 +} diff --git a/libs/@local/graph/api/openapi/models/report_context_info.json b/libs/@local/graph/api/openapi/models/report_context_info.json new file mode 100644 index 00000000000..590c9567976 --- /dev/null +++ b/libs/@local/graph/api/openapi/models/report_context_info.json @@ -0,0 +1,23 @@ +{ + "title": "ReportContextInfo", + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "The user-facing message of the status." + }, + "attachments": { + "type": "array", + "items": { + "type": "string" + } + }, + "sources": { + "type": "array", + "items": { + "$ref": "./report_context_info.json" + } + } + }, + "required": ["context", "attachments", "sources"] +} diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 9d2343d7d01..26833ae8fbb 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -1318,8 +1318,18 @@ "required": true }, "responses": { - "204": { - "description": "The validation passed" + "200": { + "description": "The validation report", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/EntityValidationReport" + } + } + } + } }, "400": { "description": "The entity validation failed" @@ -4754,10 +4764,85 @@ } } }, + "EntityTypesError": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "error" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/Report" + }, + "type": { + "type": "string", + "enum": [ + "empty" + ] + } + } + }, + { + "type": "object", + "required": [ + "type", + "error" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/Report" + }, + "type": { + "type": "string", + "enum": [ + "entityTypeRetrieval" + ] + } + } + }, + { + "type": "object", + "required": [ + "type", + "error" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/Report" + }, + "type": { + "type": "string", + "enum": [ + "resolveClosedEntityType" + ] + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + }, "EntityUuid": { "type": "string", "format": "uuid" }, + "EntityValidationReport": { + "type": "object", + "properties": { + "link": { + "$ref": "#/components/schemas/LinkValidationReport" + }, + "metadata": { + "$ref": "#/components/schemas/MetadataValidationReport" + }, + "properties": { + "$ref": "#/components/schemas/PropertyValidationReport" + } + } + }, "EntityValidationType": { "type": "array", "items": { @@ -6187,6 +6272,217 @@ }, "additionalProperties": false }, + "LinkDataStateError": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "error" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/Report" + }, + "type": { + "type": "string", + "enum": [ + "missing" + ] + } + } + }, + { + "type": "object", + "required": [ + "type", + "error" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/Report" + }, + "type": { + "type": "string", + "enum": [ + "unexpected" + ] + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "LinkDataValidationReport": { + "type": "object", + "properties": { + "leftEntity": { + "allOf": [ + { + "$ref": "#/components/schemas/LinkedEntityError" + } + ], + "nullable": true + }, + "linkType": { + "allOf": [ + { + "$ref": "#/components/schemas/LinkError" + } + ], + "nullable": true + }, + "rightEntity": { + "allOf": [ + { + "$ref": "#/components/schemas/LinkedEntityError" + } + ], + "nullable": true + }, + "targetType": { + "allOf": [ + { + "$ref": "#/components/schemas/LinkTargetError" + } + ], + "nullable": true + } + } + }, + "LinkError": { + "oneOf": [ + { + "type": "object", + "required": [ + "data", + "type" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/UnexpectedEntityType" + }, + "type": { + "type": "string", + "enum": [ + "unexpectedEntityType" + ] + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "LinkTargetError": { + "oneOf": [ + { + "type": "object", + "required": [ + "data", + "type" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/UnexpectedEntityType" + }, + "type": { + "type": "string", + "enum": [ + "unexpectedEntityType" + ] + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "LinkValidationReport": { + "allOf": [ + { + "$ref": "#/components/schemas/LinkDataValidationReport" + }, + { + "type": "object", + "properties": { + "linkData": { + "allOf": [ + { + "$ref": "#/components/schemas/LinkDataStateError" + } + ], + "nullable": true + } + } + } + ] + }, + "LinkedEntityError": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "error" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/Report" + }, + "type": { + "type": "string", + "enum": [ + "entityRetrieval" + ] + } + } + }, + { + "type": "object", + "required": [ + "type", + "error" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/Report" + }, + "type": { + "type": "string", + "enum": [ + "entityTypeRetrieval" + ] + } + } + }, + { + "type": "object", + "required": [ + "type", + "error" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/Report" + }, + "type": { + "type": "string", + "enum": [ + "resolveClosedEntityType" + ] + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + }, "LoadExternalDataTypeRequest": { "oneOf": [ { @@ -6361,6 +6657,22 @@ } ] }, + "MetadataValidationReport": { + "type": "object", + "properties": { + "entityTypes": { + "allOf": [ + { + "$ref": "#/components/schemas/EntityTypesError" + } + ], + "nullable": true + }, + "properties": { + "$ref": "#/components/schemas/PropertyMetadataValidationReport" + } + } + }, "ModifyDataTypeAuthorizationRelationship": { "type": "object", "required": [ @@ -6465,6 +6777,9 @@ } } }, + "MultiReport": { + "$ref": "./models/multi_report.json" + }, "NullOrdering": { "type": "string", "enum": [ @@ -7236,6 +7551,10 @@ }, "additionalProperties": false }, + "PropertyMetadataValidationReport": { + "default": null, + "nullable": true + }, "PropertyObject": { "type": "object", "additionalProperties": { @@ -7659,6 +7978,19 @@ } } }, + "PropertyValidationReport": { + "type": "object", + "properties": { + "error": { + "allOf": [ + { + "$ref": "#/components/schemas/Report" + } + ], + "nullable": true + } + } + }, "PropertyWithMetadata": { "oneOf": [ { @@ -7964,6 +8296,9 @@ ], "description": "Defines the two possible combinations of pinned/variable temporal axes that are used in queries\nthat return [`Subgraph`]s.\n\nThe [`VariableTemporalAxisUnresolved`] is optionally bounded, in the absence of provided\nbounds an inclusive bound at the timestamp at point of resolving is assumed.\n\n[`Subgraph`]: crate::subgraph::Subgraph" }, + "Report": { + "$ref": "./models/report.json" + }, "RightBoundedTemporalInterval": { "type": "object", "required": [ @@ -8218,6 +8553,27 @@ }, "additionalProperties": false }, + "UnexpectedEntityType": { + "type": "object", + "required": [ + "actual", + "expected" + ], + "properties": { + "actual": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VersionedUrl" + } + }, + "expected": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VersionedUrl" + } + } + } + }, "UnresolvedRightBoundedTemporalInterval": { "type": "object", "required": [ diff --git a/libs/@local/graph/api/src/rest/entity.rs b/libs/@local/graph/api/src/rest/entity.rs index a4e40b5efcd..fbcb4155a86 100644 --- a/libs/@local/graph/api/src/rest/entity.rs +++ b/libs/@local/graph/api/src/rest/entity.rs @@ -28,9 +28,12 @@ use hash_graph_store::{ ClosedMultiEntityTypeMap, CountEntitiesParams, CreateEntityRequest, DiffEntityParams, DiffEntityResult, EntityQueryCursor, EntityQueryPath, EntityQuerySorting, EntityQuerySortingRecord, EntityQuerySortingToken, EntityQueryToken, EntityStore as _, - EntityValidationType, GetEntitiesParams, GetEntitiesResponse, GetEntitySubgraphParams, - PatchEntityParams, QueryConversion, UpdateEntityEmbeddingsParams, ValidateEntityComponents, - ValidateEntityParams, + EntityTypesError, EntityValidationReport, EntityValidationType, GetEntitiesParams, + GetEntitiesResponse, GetEntitySubgraphParams, LinkDataStateError, LinkDataValidationReport, + LinkError, LinkTargetError, LinkValidationReport, LinkedEntityError, + MetadataValidationReport, PatchEntityParams, PropertyMetadataValidationReport, + PropertyValidationReport, QueryConversion, UnexpectedEntityType, + UpdateEntityEmbeddingsParams, ValidateEntityComponents, ValidateEntityParams, }, entity_type::{EntityTypeResolveDefinitions, IncludeEntityTypeOption}, filter::Filter, @@ -161,6 +164,18 @@ use crate::rest::{ EntityTemporalMetadata, EntityQueryToken, LinkData, + EntityValidationReport, + LinkedEntityError, + LinkDataValidationReport, + LinkDataStateError, + LinkValidationReport, + LinkError, + LinkTargetError, + UnexpectedEntityType, + MetadataValidationReport, + EntityTypesError, + PropertyMetadataValidationReport, + PropertyValidationReport, DiffEntityParams, DiffEntityResult, @@ -339,7 +354,7 @@ where ("X-Authenticated-User-Actor-Id" = AccountId, Header, description = "The ID of the actor which is used to authorize the request"), ), responses( - (status = 204, description = "The validation passed"), + (status = 200, content_type = "application/json", description = "The validation report", body = HashMap), (status = 400, content_type = "application/json", description = "The entity validation failed"), (status = 404, description = "Entity Type URL was not found"), @@ -356,7 +371,7 @@ async fn validate_entity( authorization_api_pool: Extension>, temporal_client: Extension>>, Json(body): Json, -) -> Result +) -> Result>, Response> where S: StorePool + Send + Sync, A: AuthorizationApiPool + Send + Sync, @@ -375,13 +390,11 @@ where .await .map_err(report_to_response)?; - store - .validate_entity(actor_id, Consistency::FullyConsistent, params) - .await - .attach(hash_status::StatusCode::InvalidArgument) - .map_err(report_to_response)?; - - Ok(StatusCode::NO_CONTENT) + Ok(Json( + store + .validate_entity(actor_id, Consistency::FullyConsistent, params) + .await, + )) } #[utoipa::path( diff --git a/libs/@local/graph/api/src/rest/json_schemas/multi_report.json b/libs/@local/graph/api/src/rest/json_schemas/multi_report.json new file mode 100644 index 00000000000..72c5757cb28 --- /dev/null +++ b/libs/@local/graph/api/src/rest/json_schemas/multi_report.json @@ -0,0 +1,9 @@ +{ + "title": "MultiReport", + "type": "array", + "description": "An error-stack Report object which may contain multiple errors", + "items": { + "$ref": "./report_context_info.json" + }, + "minItems": 1 +} diff --git a/libs/@local/graph/api/src/rest/json_schemas/report.json b/libs/@local/graph/api/src/rest/json_schemas/report.json new file mode 100644 index 00000000000..bee8ba0b57a --- /dev/null +++ b/libs/@local/graph/api/src/rest/json_schemas/report.json @@ -0,0 +1,10 @@ +{ + "title": "Report", + "type": "array", + "description": "An error-stack Report object which may contains exactly one error", + "items": { + "$ref": "./report_context_info.json" + }, + "minItems": 1, + "maxItems": 1 +} diff --git a/libs/@local/graph/api/src/rest/json_schemas/report_context_info.json b/libs/@local/graph/api/src/rest/json_schemas/report_context_info.json new file mode 100644 index 00000000000..590c9567976 --- /dev/null +++ b/libs/@local/graph/api/src/rest/json_schemas/report_context_info.json @@ -0,0 +1,23 @@ +{ + "title": "ReportContextInfo", + "type": "object", + "properties": { + "context": { + "type": "string", + "description": "The user-facing message of the status." + }, + "attachments": { + "type": "array", + "items": { + "type": "string" + } + }, + "sources": { + "type": "array", + "items": { + "$ref": "./report_context_info.json" + } + } + }, + "required": ["context", "attachments", "sources"] +} diff --git a/libs/@local/graph/api/src/rest/mod.rs b/libs/@local/graph/api/src/rest/mod.rs index c200560998f..8d18b3f761a 100644 --- a/libs/@local/graph/api/src/rest/mod.rs +++ b/libs/@local/graph/api/src/rest/mod.rs @@ -457,6 +457,8 @@ impl Modify for ExternalRefAddon { ("PartialEntityType", "partial_entity_type"), ("ClosedMultiEntityType", "closed_multi_entity_type"), ("Status", "status"), + ("Report", "report"), + ("MultiReport", "multi_report"), ] { *components.schemas.entry(name.to_owned()).or_default() = Ref::new(format!("./models/{model}.json")).into(); diff --git a/libs/@local/graph/api/src/rest/status.rs b/libs/@local/graph/api/src/rest/status.rs index 1f6fccfcb71..1902878df63 100644 --- a/libs/@local/graph/api/src/rest/status.rs +++ b/libs/@local/graph/api/src/rest/status.rs @@ -1,4 +1,5 @@ -use core::{error::Error, fmt::Debug}; +use core::{error::Error, fmt::Debug, mem}; +use std::collections::HashMap; use axum::{ Json, @@ -7,6 +8,7 @@ use axum::{ use error_stack::Report; use hash_graph_authorization::backend::PermissionAssertion; use hash_graph_postgres_store::store::error::BaseUrlAlreadyExists; +use hash_graph_store::entity::EntityValidationReport; use hash_status::{Status, StatusCode}; use serde::Serialize; @@ -27,11 +29,18 @@ where response } +#[derive(Debug, Serialize)] +#[serde(bound = "C: Error + Send + Sync + 'static")] +struct ValidationContent { + validation: HashMap, + report: Report<[C]>, +} + pub(crate) fn report_to_response(report: impl Into>) -> Response where C: Error + Send + Sync + 'static, { - let report = report.into(); + let mut report = report.into(); let status_code = report .request_ref::() .next() @@ -50,9 +59,24 @@ where // TODO: Currently, this mostly duplicates the error printed below, when more information is // added to the `Report` event consider commenting in this line again. // hash_tracing::sentry::capture_report(&report); - tracing::error!(error = ?report, tags.code = ?status_code.to_http_code()); - status_to_response(Status::new(status_code, Some(report.to_string()), vec![ - report, - ])) + let message = report.to_string(); + if let Some(validation) = report + .downcast_mut::>() + .map(mem::take) + { + tracing::error!(error = ?report, ?validation, tags.code = ?status_code.to_http_code()); + let status_code = if !validation.is_empty() && status_code == StatusCode::Unknown { + StatusCode::InvalidArgument + } else { + status_code + }; + + status_to_response(Status::new(status_code, Some(message), vec![ + ValidationContent { validation, report }, + ])) + } else { + tracing::error!(error = ?report, tags.code = ?status_code.to_http_code()); + status_to_response(Status::new(status_code, Some(message), vec![report])) + } } diff --git a/libs/@local/graph/postgres-store/src/snapshot/entity/batch.rs b/libs/@local/graph/postgres-store/src/snapshot/entity/batch.rs index 80984266b27..f4e6048d572 100644 --- a/libs/@local/graph/postgres-store/src/snapshot/entity/batch.rs +++ b/libs/@local/graph/postgres-store/src/snapshot/entity/batch.rs @@ -1,10 +1,15 @@ -use error_stack::{Report, ResultExt as _}; +use std::collections::HashMap; + +use error_stack::{Report, ResultExt as _, ensure}; use futures::{StreamExt as _, TryStreamExt as _, stream}; use hash_graph_authorization::{ AuthorizationApi, backend::ZanzibarBackend, schema::EntityRelationAndSubject, }; use hash_graph_store::{ - entity::{EntityStore as _, ValidateEntityComponents}, + entity::{ + EntityStore as _, EntityValidationReport, PropertyValidationReport, + ValidateEntityComponents, + }, error::InsertionError, filter::Filter, query::Read, @@ -305,7 +310,8 @@ where let mut properties_updates = Vec::new(); let mut metadata_updates = Vec::new(); - for mut entity in entities { + let mut validation_reports = HashMap::::new(); + for (index, mut entity) in entities.into_iter().enumerate() { let validation_components = if entity.metadata.record_id.entity_id.draft_id.is_some() { ValidateEntityComponents::draft() } else { @@ -333,16 +339,21 @@ where ) .change_context(InsertionError)?; - EntityPreprocessor { + let mut preprocessor = EntityPreprocessor { components: validation_components, + }; + + if let Err(error) = preprocessor + .visit_object( + &entity_type, + &mut property_with_metadata, + &validator_provider, + ) + .await + { + validation_reports.entry(index).or_default().properties = + PropertyValidationReport { error: Some(error) }; } - .visit_object( - &entity_type, - &mut property_with_metadata, - &validator_provider, - ) - .await - .change_context(InsertionError)?; let (properties, metadata) = property_with_metadata.into_parts(); let mut changed = false; @@ -364,10 +375,14 @@ where }; validation_components.link_validation = postgres_client.settings.validate_links; - entity + let validation_report = entity .validate(&entity_type, validation_components, &validator_provider) - .await - .change_context(InsertionError)?; + .await; + if !validation_report.is_valid() { + let validation = validation_reports.entry(index).or_default(); + validation.link = validation_report.link; + validation.metadata.properties = validation_report.property_metadata; + } if changed { edition_ids_updates.push(entity.metadata.record_id.edition_id); @@ -376,6 +391,11 @@ where } } + ensure!( + validation_reports.is_empty(), + Report::new(InsertionError).attach(validation_reports) + ); + postgres_client .as_client() .client() 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 1daf5e4b215..729490e66c7 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 @@ -4,7 +4,7 @@ use alloc::{borrow::Cow, collections::BTreeSet}; use core::{borrow::Borrow as _, iter::once, mem}; use std::collections::{HashMap, HashSet}; -use error_stack::{Report, ReportSink, ResultExt as _, bail}; +use error_stack::{FutureExt as _, Report, ResultExt as _, TryReportStreamExt as _, bail, ensure}; use futures::{StreamExt as _, TryStreamExt as _, stream}; use hash_graph_authorization::{ AuthorizationApi, @@ -17,11 +17,12 @@ use hash_graph_authorization::{ }; use hash_graph_store::{ entity::{ - ClosedMultiEntityTypeMap, CountEntitiesParams, CreateEntityParams, EntityQueryPath, - EntityQuerySorting, EntityStore, EntityValidationType, GetEntitiesParams, - GetEntitiesResponse, GetEntitySubgraphParams, GetEntitySubgraphResponse, PatchEntityParams, - QueryConversion, UpdateEntityEmbeddingsParams, ValidateEntityComponents, - ValidateEntityError, ValidateEntityParams, + ClosedMultiEntityTypeMap, CountEntitiesParams, CreateEntityParams, EmptyEntityTypes, + EntityQueryPath, EntityQuerySorting, EntityStore, EntityTypeRetrieval, EntityTypesError, + EntityValidationReport, EntityValidationType, GetEntitiesParams, GetEntitiesResponse, + GetEntitySubgraphParams, GetEntitySubgraphResponse, PatchEntityParams, + PropertyValidationReport, QueryConversion, UpdateEntityEmbeddingsParams, + ValidateEntityComponents, ValidateEntityParams, }, entity_type::IncludeEntityTypeOption, error::{InsertionError, QueryError, UpdateError}, @@ -62,7 +63,7 @@ use hash_graph_types::{ ontology::{DataTypeLookup, OntologyTypeProvider}, owned_by_id::OwnedById, }; -use hash_graph_validation::{EntityPreprocessor, EntityValidationError, Validate as _}; +use hash_graph_validation::{EntityPreprocessor, Validate as _}; use hash_status::StatusCode; use postgres_types::ToSql; use serde_json::Value as JsonValue; @@ -770,7 +771,8 @@ where authorization: Some((actor_id, Consistency::FullyConsistent)), }; - for mut params in params { + let mut validation_reports = HashMap::::new(); + for (index, mut params) in params.into_iter().enumerate() { let entity_type = ClosedMultiEntityType::from_multi_type_closed_schema( stream::iter(¶ms.entity_type_ids) .then(|entity_type_url| async { @@ -787,19 +789,22 @@ where ) .change_context(InsertionError)?; - let mut validation_components = if params.draft { - ValidateEntityComponents::draft() - } else { - ValidateEntityComponents::full() + let mut preprocessor = EntityPreprocessor { + components: if params.draft { + ValidateEntityComponents::draft() + } else { + ValidateEntityComponents::full() + }, }; - validation_components.link_validation = self.settings.validate_links; - EntityPreprocessor { - components: validation_components, + preprocessor.components.link_validation = self.settings.validate_links; + + if let Err(error) = preprocessor + .visit_object(&entity_type, &mut params.properties, &validator_provider) + .await + { + validation_reports.entry(index).or_default().properties = + PropertyValidationReport { error: Some(error) }; } - .visit_object(&entity_type, &mut params.properties, &validator_provider) - .await - .attach(StatusCode::InvalidArgument) - .change_context(InsertionError)?; let (properties, property_metadata) = params.properties.into_parts(); @@ -927,7 +932,7 @@ where }, }); - validation_params.push((entity_type, validation_components)); + validation_params.push((entity_type, preprocessor.components)); let current_num_relationships = relationships.len(); relationships.extend( @@ -1090,13 +1095,24 @@ where authorization: Some((actor_id, Consistency::FullyConsistent)), }; - for (entity, (schema, components)) in entities.iter().zip(validation_params) { - entity + for (index, (entity, (schema, components))) in + entities.iter().zip(validation_params).enumerate() + { + let validation_report = entity .validate(&schema, components, &validator_provider) - .await - .change_context(InsertionError)?; + .await; + if !validation_report.is_valid() { + let report = validation_reports.entry(index).or_default(); + report.link = validation_report.link; + report.metadata.properties = validation_report.property_metadata; + } } + ensure!( + validation_reports.is_empty(), + Report::new(InsertionError).attach(validation_reports) + ); + let commit_result = transaction.commit().await.change_context(InsertionError); if let Err(error) = commit_result { let mut error = error.expand(); @@ -1141,8 +1157,8 @@ where actor_id: AccountId, consistency: Consistency<'_>, params: Vec>, - ) -> Result<(), Report> { - let mut status = ReportSink::new(); + ) -> HashMap { + let mut validation_reports = HashMap::::new(); let validator_provider = StoreProvider { store: self, @@ -1150,61 +1166,76 @@ where authorization: Some((actor_id, Consistency::FullyConsistent)), }; - for mut params in params { + for (index, mut params) in params.into_iter().enumerate() { + let mut validation_report = EntityValidationReport::default(); + let schema = match params.entity_types { EntityValidationType::ClosedSchema(schema) => schema, - EntityValidationType::Id(entity_type_urls) => Cow::Owned( - ClosedMultiEntityType::from_multi_type_closed_schema( - stream::iter(entity_type_urls.as_ref()) - .then(|entity_type_url| async { - OntologyTypeProvider::::provide_type( - &validator_provider, - entity_type_url, - ) - .await - .map(|entity_type| (*entity_type).clone()) + EntityValidationType::Id(entity_type_urls) => { + let entity_type = stream::iter(entity_type_urls.as_ref()) + .then(|entity_type_url| { + OntologyTypeProvider::::provide_type( + &validator_provider, + entity_type_url, + ) + .change_context_lazy(|| { + EntityTypeRetrieval { + entity_type_url: entity_type_url.clone(), + } }) - .try_collect::>() - .await - .change_context(ValidateEntityError)?, - ) - .change_context(ValidateEntityError)?, - ), + }) + .map_ok(|entity_type| (*entity_type).clone()) + .try_collect_reports::>() + .await + .map_err(EntityTypesError::EntityTypeRetrieval) + .and_then(|entity_types| { + ClosedMultiEntityType::from_multi_type_closed_schema(entity_types) + .map_err(EntityTypesError::ResolveClosedEntityType) + }); + match entity_type { + Ok(entity_type) => Cow::Owned(entity_type), + Err(error) => { + validation_report.metadata.entity_types = Some(error); + validation_reports.insert(index, validation_report); + continue; + } + } + } }; if schema.all_of.is_empty() { - let error = Report::new(EntityValidationError::EmptyEntityTypes); - status.append(error); + validation_report.metadata.entity_types = + Some(EntityTypesError::Empty(Report::new(EmptyEntityTypes))); }; - let pre_process_result = EntityPreprocessor { + let mut preprocessor = EntityPreprocessor { components: params.components, - } - .visit_object( - schema.as_ref(), - params.properties.to_mut(), - &validator_provider, - ) - .await - .change_context(EntityValidationError::InvalidProperties); - if let Err(error) = pre_process_result { - status.append(error); + }; + + if let Err(error) = preprocessor + .visit_object( + schema.as_ref(), + params.properties.to_mut(), + &validator_provider, + ) + .await + { + validation_reports.entry(index).or_default().properties = + PropertyValidationReport { error: Some(error) }; } - if let Err(error) = params + validation_report.link = params .link_data .as_deref() .validate(&schema, params.components, &validator_provider) - .await - { - status.append(error); + .await; + + if !validation_report.is_valid() { + validation_reports.insert(index, validation_report); } } - status - .finish() - .change_context(ValidateEntityError) - .attach(StatusCode::InvalidArgument) + validation_reports } #[tracing::instrument(level = "info", skip(self, params))] @@ -1698,15 +1729,19 @@ where }; validation_components.link_validation = transaction.settings.validate_links; + let mut validation_report = EntityValidationReport::default(); let (properties, property_metadata) = if let PropertyWithMetadata::Object(mut object) = properties_with_metadata { - EntityPreprocessor { + let mut preprocessor = EntityPreprocessor { components: validation_components, + }; + if let Err(error) = preprocessor + .visit_object(&entity_type, &mut object, &validator_provider) + .await + { + validation_report.properties = PropertyValidationReport { error: Some(error) }; } - .visit_object(&entity_type, &mut object, &validator_provider) - .await - .attach(StatusCode::InvalidArgument) - .change_context(UpdateError)?; + let (properties, property_metadata) = object.into_parts(); (properties, property_metadata) } else { @@ -1891,10 +1926,19 @@ where cache: store_cache, authorization: Some((actor_id, Consistency::FullyConsistent)), }; - entities[0] + let post_validation_report = entities[0] .validate(&entity_type, validation_components, &validator_provider) - .await - .change_context(UpdateError)?; + .await; + validation_report.link = post_validation_report.link; + validation_report.metadata.properties = post_validation_report.property_metadata; + + ensure!( + validation_report.is_valid(), + Report::new(UpdateError).attach(HashMap::from([( + entities[0].metadata.record_id.entity_id, + validation_report + )])) + ); transaction.commit().await.change_context(UpdateError)?; diff --git a/libs/@local/graph/postgres-store/src/store/validation.rs b/libs/@local/graph/postgres-store/src/store/validation.rs index 3f45bab6bf0..93a5abfb765 100644 --- a/libs/@local/graph/postgres-store/src/store/validation.rs +++ b/libs/@local/graph/postgres-store/src/store/validation.rs @@ -22,8 +22,8 @@ use hash_graph_types::{ account::AccountId, knowledge::entity::{Entity, EntityId}, ontology::{ - DataTypeLookup, DataTypeWithMetadata, EntityTypeProvider, EntityTypeWithMetadata, - OntologyTypeProvider, PropertyTypeProvider, PropertyTypeWithMetadata, + DataTypeLookup, DataTypeWithMetadata, EntityTypeWithMetadata, OntologyTypeProvider, + PropertyTypeWithMetadata, }, }; use hash_graph_validation::EntityProvider; @@ -407,13 +407,6 @@ where } } -impl PropertyTypeProvider for StoreProvider<'_, PostgresStore> -where - C: AsClient, - A: AuthorizationApi, -{ -} - impl StoreProvider<'_, PostgresStore> where C: AsClient, @@ -518,71 +511,6 @@ where } } -impl EntityTypeProvider for StoreProvider<'_, PostgresStore> -where - C: AsClient, - A: AuthorizationApi, -{ - #[expect(refining_impl_trait)] - async fn is_super_type_of( - &self, - parent: &VersionedUrl, - child: &VersionedUrl, - ) -> Result> { - let client = self.store.as_client().client(); - let child_id = EntityTypeUuid::from_url(child); - let parent_id = EntityTypeUuid::from_url(parent); - - Ok(client - .query_one( - " - SELECT EXISTS ( - SELECT 1 FROM entity_type_inherits_from - WHERE source_entity_type_ontology_id = $1 - AND target_entity_type_ontology_id = $2 - ); - ", - &[&child_id, &parent_id], - ) - .await - .change_context(QueryError)? - .get(0)) - } - - #[expect(refining_impl_trait)] - async fn find_parents( - &self, - entity_types: &[VersionedUrl], - ) -> Result, Report> { - let entity_type_ids = entity_types - .iter() - .map(EntityTypeUuid::from_url) - .collect::>(); - - Ok(self - .store - .as_client() - .query( - " - SELECT base_url, version - FROM entity_type_inherits_from - JOIN ontology_ids ON target_entity_type_ontology_id = ontology_id - WHERE source_entity_type_ontology_id = ANY($1) - ORDER BY depth ASC; - ", - &[&entity_type_ids], - ) - .await - .change_context(QueryError)? - .into_iter() - .map(|row| VersionedUrl { - base_url: row.get(0), - version: row.get(1), - }) - .collect()) - } -} - impl EntityProvider for StoreProvider<'_, PostgresStore> where C: AsClient, diff --git a/libs/@local/graph/sdk/typescript/src/entity.ts b/libs/@local/graph/sdk/typescript/src/entity.ts index a38743c494c..ddaa65c510e 100644 --- a/libs/@local/graph/sdk/typescript/src/entity.ts +++ b/libs/@local/graph/sdk/typescript/src/entity.ts @@ -3,6 +3,7 @@ import { typedEntries, typedKeys } from "@local/advanced-types/typed-entries"; import type { CreateEntityRequest as GraphApiCreateEntityRequest, Entity as GraphApiEntity, + EntityValidationReport, GraphApi, OriginProvenance, PatchEntityParams as GraphApiPatchEntityParams, @@ -779,10 +780,10 @@ export class Entity { params: Omit & { properties: PropertyObjectWithMetadata; }, - ): Promise { + ): Promise { return await graphAPI .validateEntity(authentication.actorId, params) - .then(({ data }) => data); + .then(({ data }) => data["0"]); } public async patch( diff --git a/libs/@local/graph/store/Cargo.toml b/libs/@local/graph/store/Cargo.toml index eaed681396e..160f5ac42c9 100644 --- a/libs/@local/graph/store/Cargo.toml +++ b/libs/@local/graph/store/Cargo.toml @@ -8,7 +8,7 @@ authors.workspace = true [dependencies] # Public workspace dependencies -error-stack = { workspace = true } +error-stack = { workspace = true, features = ["serde"] } hash-graph-authorization = { workspace = true, public = true } hash-temporal-client = { workspace = true, public = true } diff --git a/libs/@local/graph/store/src/entity/mod.rs b/libs/@local/graph/store/src/entity/mod.rs index 38dd395e302..5e18edb0389 100644 --- a/libs/@local/graph/store/src/entity/mod.rs +++ b/libs/@local/graph/store/src/entity/mod.rs @@ -12,10 +12,18 @@ pub use self::{ QueryConversion, UpdateEntityEmbeddingsParams, ValidateEntityComponents, ValidateEntityError, ValidateEntityParams, }, + validation_report::{ + EmptyEntityTypes, EntityRetrieval, EntityTypeRetrieval, EntityTypesError, + EntityValidationReport, LinkDataStateError, LinkDataValidationReport, LinkError, + LinkTargetError, LinkValidationReport, LinkedEntityError, MetadataValidationReport, + MissingLinkData, PropertyMetadataValidationReport, PropertyValidationReport, + UnexpectedEntityType, UnexpectedLinkData, + }, }; mod query; mod store; +mod validation_report; use hash_graph_types::knowledge::entity::Entity; diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index dcdbe73fe80..aa9861d7337 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -26,7 +26,7 @@ use utoipa::{ }; use crate::{ - entity::{EntityQueryCursor, EntityQuerySorting}, + entity::{EntityQueryCursor, EntityQuerySorting, EntityValidationReport}, entity_type::{EntityTypeResolveDefinitions, IncludeEntityTypeOption}, error::{InsertionError, QueryError, UpdateError}, filter::Filter, @@ -383,7 +383,7 @@ pub trait EntityStore { actor_id: AccountId, consistency: Consistency<'_>, params: ValidateEntityParams<'_>, - ) -> impl Future>> + Send { + ) -> impl Future> + Send { self.validate_entities(actor_id, consistency, vec![params]) } @@ -397,7 +397,7 @@ pub trait EntityStore { actor_id: AccountId, consistency: Consistency<'_>, params: Vec>, - ) -> impl Future>> + Send; + ) -> impl Future> + Send; /// Get a list of entities specified by the [`GetEntitiesParams`]. /// diff --git a/libs/@local/graph/store/src/entity/validation_report.rs b/libs/@local/graph/store/src/entity/validation_report.rs new file mode 100644 index 00000000000..3ce34a6a02c --- /dev/null +++ b/libs/@local/graph/store/src/entity/validation_report.rs @@ -0,0 +1,208 @@ +use std::collections::HashSet; + +use error_stack::Report; +use hash_graph_types::knowledge::{entity::EntityId, property::visitor::TraversalError}; +use type_system::{schema::ResolveClosedEntityTypeError, url::VersionedUrl}; + +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("Could not read the entity")] +#[must_use] +pub struct EntityRetrieval { + pub entity_id: EntityId, +} + +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("Could not read the entity type {entity_type_url}")] +#[must_use] +pub struct EntityTypeRetrieval { + pub entity_type_url: VersionedUrl, +} + +#[derive(Debug, serde::Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", content = "error", rename_all = "camelCase")] +#[must_use] +pub enum LinkedEntityError { + EntityRetrieval(Report), + EntityTypeRetrieval( + #[cfg_attr(feature = "utoipa", schema(value_type = MultiReport))] + Report<[EntityTypeRetrieval]>, + ), + ResolveClosedEntityType(Report), +} + +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("The entity is a link but does not contain link data")] +#[must_use] +pub struct MissingLinkData; + +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("The entity is not a link but contains link data")] +#[must_use] +pub struct UnexpectedLinkData; + +#[derive(Debug, serde::Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", content = "error", rename_all = "camelCase")] +#[must_use] +pub enum LinkDataStateError { + Missing(Report), + Unexpected(Report), +} + +#[derive(Debug, serde::Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct UnexpectedEntityType { + #[cfg_attr(feature = "utoipa", schema(value_type = Vec))] + pub actual: HashSet, + #[cfg_attr(feature = "utoipa", schema(value_type = Vec))] + pub expected: HashSet, +} + +#[derive(Debug, serde::Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +#[must_use] +pub enum LinkError { + UnexpectedEntityType { data: UnexpectedEntityType }, +} + +#[derive(Debug, serde::Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +#[must_use] +pub enum LinkTargetError { + UnexpectedEntityType { data: UnexpectedEntityType }, +} + +#[derive(Debug, Default, serde::Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +#[must_use] +pub struct LinkDataValidationReport { + #[serde(skip_serializing_if = "Option::is_none")] + pub left_entity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub right_entity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub link_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_type: Option, +} + +impl LinkDataValidationReport { + #[must_use] + pub const fn is_valid(&self) -> bool { + self.left_entity.is_none() + && self.right_entity.is_none() + && self.link_type.is_none() + && self.target_type.is_none() + } +} + +#[derive(Debug, Default, serde::Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +#[must_use] +pub struct LinkValidationReport { + #[serde(skip_serializing_if = "Option::is_none")] + pub link_data: Option, + #[serde(flatten)] + pub link_data_validation: LinkDataValidationReport, +} + +impl LinkValidationReport { + #[must_use] + pub const fn is_valid(&self) -> bool { + self.link_data.is_none() && self.link_data_validation.is_valid() + } +} + +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("The entity does not contain any entity types")] +#[must_use] +pub struct EmptyEntityTypes; + +#[derive(Debug, serde::Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(tag = "type", content = "error", rename_all = "camelCase")] +#[must_use] +pub enum EntityTypesError { + Empty(Report), + EntityTypeRetrieval( + #[cfg_attr(feature = "utoipa", schema(value_type = MultiReport))] + Report<[EntityTypeRetrieval]>, + ), + ResolveClosedEntityType(Report), +} + +#[derive(Debug, Default, serde::Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +#[must_use] +pub struct PropertyMetadataValidationReport; + +impl PropertyMetadataValidationReport { + #[must_use] + #[expect( + clippy::unused_self, + reason = "The struct will be extended in the future" + )] + pub const fn is_valid(&self) -> bool { + true + } +} + +#[derive(Debug, Default, serde::Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +#[must_use] +pub struct MetadataValidationReport { + #[serde(skip_serializing_if = "Option::is_none")] + pub entity_types: Option, + #[serde(skip_serializing_if = "PropertyMetadataValidationReport::is_valid")] + pub properties: PropertyMetadataValidationReport, +} + +impl MetadataValidationReport { + #[must_use] + pub const fn is_valid(&self) -> bool { + self.entity_types.is_none() && self.properties.is_valid() + } +} + +#[derive(Debug, Default, serde::Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +#[must_use] +pub struct PropertyValidationReport { + pub error: Option>, +} + +impl PropertyValidationReport { + #[must_use] + pub const fn is_valid(&self) -> bool { + self.error.is_none() + } +} + +#[derive(Debug, Default, serde::Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +#[must_use] +pub struct EntityValidationReport { + #[serde(skip_serializing_if = "PropertyValidationReport::is_valid")] + pub properties: PropertyValidationReport, + #[serde(skip_serializing_if = "LinkValidationReport::is_valid")] + pub link: LinkValidationReport, + #[serde(skip_serializing_if = "MetadataValidationReport::is_valid")] + pub metadata: MetadataValidationReport, +} + +impl EntityValidationReport { + #[must_use] + pub const fn is_valid(&self) -> bool { + self.link.is_valid() && self.metadata.is_valid() + } +} diff --git a/libs/@local/graph/type-fetcher/src/store.rs b/libs/@local/graph/type-fetcher/src/store.rs index ec21866f2d8..be2b441db98 100644 --- a/libs/@local/graph/type-fetcher/src/store.rs +++ b/libs/@local/graph/type-fetcher/src/store.rs @@ -25,9 +25,9 @@ use hash_graph_store::{ UpdateDataTypesParams, }, entity::{ - CountEntitiesParams, CreateEntityParams, EntityStore, GetEntitiesParams, - GetEntitiesResponse, GetEntitySubgraphParams, GetEntitySubgraphResponse, PatchEntityParams, - UpdateEntityEmbeddingsParams, ValidateEntityError, ValidateEntityParams, + CountEntitiesParams, CreateEntityParams, EntityStore, EntityValidationReport, + GetEntitiesParams, GetEntitiesResponse, GetEntitySubgraphParams, GetEntitySubgraphResponse, + PatchEntityParams, UpdateEntityEmbeddingsParams, ValidateEntityParams, }, entity_type::{ ArchiveEntityTypeParams, CountEntityTypesParams, CreateEntityTypeParams, EntityTypeStore, @@ -1204,7 +1204,7 @@ where actor_id: AccountId, consistency: Consistency<'_>, params: Vec>, - ) -> Result<(), Report> { + ) -> HashMap { self.store .validate_entities(actor_id, consistency, params) .await diff --git a/libs/@local/graph/types/rust/src/knowledge/property/visitor.rs b/libs/@local/graph/types/rust/src/knowledge/property/visitor.rs index 72ed7dddb30..17634e1d226 100644 --- a/libs/@local/graph/types/rust/src/knowledge/property/visitor.rs +++ b/libs/@local/graph/types/rust/src/knowledge/property/visitor.rs @@ -17,7 +17,7 @@ use crate::{ PropertyWithMetadataValue, ValueMetadata, error::{Actual, Expected}, }, - ontology::{DataTypeLookup, DataTypeWithMetadata, OntologyTypeProvider, PropertyTypeProvider}, + ontology::{DataTypeLookup, DataTypeWithMetadata, OntologyTypeProvider}, }; #[derive(Debug, thiserror::Error)] @@ -124,7 +124,7 @@ pub trait EntityVisitor: Sized + Send + Sync { type_provider: &P, ) -> impl Future>> + Send where - P: DataTypeLookup + PropertyTypeProvider + Sync, + P: DataTypeLookup + OntologyTypeProvider + Sync, { walk_property(self, schema, property, type_provider) } @@ -140,7 +140,7 @@ pub trait EntityVisitor: Sized + Send + Sync { ) -> impl Future>> + Send where T: PropertyValueSchema + Sync, - P: DataTypeLookup + PropertyTypeProvider + Sync, + P: DataTypeLookup + OntologyTypeProvider + Sync, { walk_array(self, schema, array, type_provider) } @@ -156,7 +156,7 @@ pub trait EntityVisitor: Sized + Send + Sync { ) -> impl Future>> + Send where T: PropertyObjectSchema> + Sync, - P: DataTypeLookup + PropertyTypeProvider + Sync, + P: DataTypeLookup + OntologyTypeProvider + Sync, { walk_object(self, schema, object, type_provider) } @@ -186,7 +186,7 @@ pub trait EntityVisitor: Sized + Send + Sync { type_provider: &P, ) -> impl Future>> + Send where - P: DataTypeLookup + PropertyTypeProvider + Sync, + P: DataTypeLookup + OntologyTypeProvider + Sync, { walk_one_of_array(self, schema, array, type_provider) } @@ -201,7 +201,7 @@ pub trait EntityVisitor: Sized + Send + Sync { type_provider: &P, ) -> impl Future>> + Send where - P: DataTypeLookup + PropertyTypeProvider + Sync, + P: DataTypeLookup + OntologyTypeProvider + Sync, { walk_one_of_object(self, schema, object, type_provider) } @@ -268,7 +268,7 @@ pub async fn walk_property( ) -> Result<(), Report<[TraversalError]>> where V: EntityVisitor, - P: DataTypeLookup + PropertyTypeProvider + Sync, + P: DataTypeLookup + OntologyTypeProvider + Sync, { let mut status = ReportSink::new(); match property { @@ -314,7 +314,7 @@ pub async fn walk_array( where V: EntityVisitor, S: PropertyValueSchema + Sync, - P: DataTypeLookup + PropertyTypeProvider + Sync, + P: DataTypeLookup + OntologyTypeProvider + Sync, { let mut status = ReportSink::new(); @@ -378,7 +378,7 @@ pub async fn walk_object( where V: EntityVisitor, S: PropertyObjectSchema> + Sync, - P: DataTypeLookup + PropertyTypeProvider + Sync, + P: DataTypeLookup + OntologyTypeProvider + Sync, { let mut status = ReportSink::new(); @@ -543,7 +543,7 @@ pub async fn walk_one_of_array( ) -> Result<(), Report<[TraversalError]>> where V: EntityVisitor, - P: DataTypeLookup + PropertyTypeProvider + Sync, + P: DataTypeLookup + OntologyTypeProvider + Sync, { let mut status = ReportSink::new(); let mut passed: usize = 0; @@ -607,7 +607,7 @@ pub async fn walk_one_of_object( ) -> Result<(), Report<[TraversalError]>> where V: EntityVisitor, - P: DataTypeLookup + PropertyTypeProvider + Sync, + P: DataTypeLookup + OntologyTypeProvider + Sync, { let mut status = ReportSink::new(); let mut passed: usize = 0; diff --git a/libs/@local/graph/types/rust/src/ontology/mod.rs b/libs/@local/graph/types/rust/src/ontology/mod.rs index 7e763a7694e..43a9b1ffbd1 100644 --- a/libs/@local/graph/types/rust/src/ontology/mod.rs +++ b/libs/@local/graph/types/rust/src/ontology/mod.rs @@ -10,10 +10,7 @@ use hash_graph_temporal_versioning::{LeftClosedTemporalInterval, TransactionTime use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use type_system::{ - schema::{ - ClosedEntityType, ConversionExpression, DataTypeReference, EntityTypeReference, - PropertyType, PropertyTypeReference, - }, + schema::{DataTypeReference, EntityTypeReference, PropertyTypeReference}, url::{BaseUrl, OntologyTypeVersion, VersionedUrl}, }; @@ -175,42 +172,8 @@ pub trait OntologyTypeProvider { ) -> impl Future>> + Send; } -pub trait DataTypeProvider: OntologyTypeProvider { - fn is_parent_of( - &self, - child: &VersionedUrl, - parent: &BaseUrl, - ) -> impl Future>> + Send; - - fn find_conversion( - &self, - source_data_type_id: &VersionedUrl, - target_data_type_id: &VersionedUrl, - ) -> impl Future< - Output = Result< - impl Borrow>, - Report, - >, - > + Send; -} - -pub trait PropertyTypeProvider: OntologyTypeProvider {} - pub enum EntityTypeVariance { Covariant, Contravariant, Invariant, } - -pub trait EntityTypeProvider: OntologyTypeProvider { - fn is_super_type_of( - &self, - parent: &VersionedUrl, - child: &VersionedUrl, - ) -> impl Future>> + Send; - - fn find_parents( - &self, - entity_types: &[VersionedUrl], - ) -> impl Future, Report>> + Send; -} diff --git a/libs/@local/graph/validation/Cargo.toml b/libs/@local/graph/validation/Cargo.toml index e24f7854d32..4bc008d5bcf 100644 --- a/libs/@local/graph/validation/Cargo.toml +++ b/libs/@local/graph/validation/Cargo.toml @@ -14,18 +14,19 @@ hash-graph-types = { workspace = true, public = true } # Public third-party dependencies # Private workspace dependencies -error-stack = { workspace = true, features = ["hooks"] } +error-stack = { workspace = true, features = ["hooks", "unstable", "futures"] } type-system = { workspace = true } # Private third-party dependencies -futures = { workspace = true } -regex = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -thiserror = { workspace = true } -url = { workspace = true } -utoipa = { workspace = true, optional = true } -uuid = { workspace = true, features = ["std"] } +derive_more = { workspace = true, features = ["display", "error"] } +futures = { workspace = true } +regex = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } +utoipa = { workspace = true, optional = true } +uuid = { workspace = true, features = ["std"] } [dev-dependencies] hash-graph-temporal-versioning = { workspace = true } diff --git a/libs/@local/graph/validation/src/entity_type.rs b/libs/@local/graph/validation/src/entity_type.rs index babf4dc91cf..372374e8ab2 100644 --- a/libs/@local/graph/validation/src/entity_type.rs +++ b/libs/@local/graph/validation/src/entity_type.rs @@ -1,9 +1,14 @@ use core::borrow::Borrow as _; use std::collections::{HashSet, hash_map::RawEntryMut}; -use error_stack::{Report, ReportSink, ResultExt as _}; +use error_stack::{FutureExt as _, Report, ReportSink, ResultExt as _, TryReportStreamExt as _}; use futures::{StreamExt as _, TryStreamExt as _, stream}; -use hash_graph_store::entity::ValidateEntityComponents; +use hash_graph_store::entity::{ + EntityRetrieval, EntityTypeRetrieval, LinkDataStateError, LinkDataValidationReport, LinkError, + LinkTargetError, LinkValidationReport, LinkedEntityError, MissingLinkData, + PropertyMetadataValidationReport, UnexpectedEntityType, UnexpectedLinkData, + ValidateEntityComponents, +}; use hash_graph_types::{ knowledge::{ entity::{Entity, EntityId}, @@ -17,10 +22,7 @@ use hash_graph_types::{ }, }, }, - ontology::{ - DataTypeLookup, DataTypeWithMetadata, EntityTypeProvider, OntologyTypeProvider, - PropertyTypeProvider, - }, + ontology::{DataTypeLookup, DataTypeWithMetadata, OntologyTypeProvider}, }; use serde_json::Value as JsonValue; use thiserror::Error; @@ -30,19 +32,15 @@ use type_system::{ JsonSchemaValueType, PropertyObjectSchema, PropertyType, PropertyTypeReference, PropertyValueArray, PropertyValueSchema, PropertyValues, ValueOrArray, }, - url::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + url::VersionedUrl, }; -use crate::{EntityProvider, Schema, Validate}; +use crate::{EntityProvider, Validate}; #[derive(Debug, Error)] pub enum EntityValidationError { #[error("The properties of the entity do not match the schema")] InvalidProperties, - #[error("The entity is not a link but contains link data")] - UnexpectedLinkData, - #[error("The entity is a link but does not contain link data")] - MissingLinkData, #[error("Entities without a type are not allowed")] EmptyEntityTypes, #[error("the validator was unable to read the entity type `{ids:?}`")] @@ -60,202 +58,170 @@ pub enum EntityValidationError { impl

Validate for Option<&LinkData> where P: EntityProvider - + EntityTypeProvider + + OntologyTypeProvider + OntologyTypeProvider + DataTypeLookup + Sync, { - type Error = EntityValidationError; + type Report = LinkValidationReport; async fn validate( &self, schema: &ClosedMultiEntityType, components: ValidateEntityComponents, context: &P, - ) -> Result<(), Report<[Self::Error]>> { - if !components.link_data { - return Ok(()); - } - - let mut status = ReportSink::new(); - - // TODO: The link type should be a const but the type system crate does not allow - // to make this a `const` variable. - // see https://linear.app/hash/issue/BP-57 - let link_type_id = VersionedUrl { - base_url: BaseUrl::new( - "https://blockprotocol.org/@blockprotocol/types/entity-type/link/".to_owned(), - ) - .expect("Not a valid URL"), - version: OntologyTypeVersion::new(1), - }; - - let mut is_link = false; - for entity_type in &schema.all_of { - if context - .is_super_type_of(&link_type_id, &entity_type.id) - .await - .change_context_lazy(|| EntityValidationError::EntityTypeRetrieval { - ids: HashSet::from([entity_type.id.clone()]), - })? - { - is_link = true; - break; - } - } + ) -> Self::Report { + let mut validation_report = LinkValidationReport::default(); + let is_link = schema.is_link(); if let Some(link_data) = self { if !is_link { - status.capture(EntityValidationError::UnexpectedLinkData); + validation_report.link_data = Some(LinkDataStateError::Unexpected(Report::new( + UnexpectedLinkData, + ))); } - if let Err(error) = schema.validate_value(*link_data, components, context).await { - status.append(error); + if components.link_validation { + validation_report.link_data_validation = + link_data.validate(schema, components, context).await; } } else if is_link { - status.capture(EntityValidationError::MissingLinkData); + validation_report.link_data = + Some(LinkDataStateError::Missing(Report::new(MissingLinkData))); } - status.finish() + validation_report + } +} + +#[derive(Debug)] +#[must_use] +pub struct PostInsertionEntityValidationReport { + pub link: LinkValidationReport, + pub property_metadata: PropertyMetadataValidationReport, +} + +impl PostInsertionEntityValidationReport { + #[must_use] + pub const fn is_valid(&self) -> bool { + self.link.is_valid() && self.property_metadata.is_valid() } } impl

Validate for Entity where P: EntityProvider - + EntityTypeProvider + + OntologyTypeProvider + OntologyTypeProvider + DataTypeLookup + Sync, { - type Error = EntityValidationError; + type Report = PostInsertionEntityValidationReport; async fn validate( &self, schema: &ClosedMultiEntityType, components: ValidateEntityComponents, context: &P, - ) -> Result<(), Report<[Self::Error]>> { - let mut status = ReportSink::new(); - - if self.metadata.entity_type_ids.is_empty() { - status.capture(EntityValidationError::EmptyEntityTypes); - } - - if components.link_validation { - if let Err(error) = self + ) -> Self::Report { + PostInsertionEntityValidationReport { + link: self .link_data .as_ref() .validate(schema, components, context) - .await - { - status.append(error); - } + .await, + property_metadata: self + .metadata + .properties + .validate(&self.properties, components, context) + .await, } + } +} - if let Err(error) = self - .metadata - .properties - .validate(&self.properties, components, context) - .await - { - status.append(error); - } +async fn read_entity_type

( + entity_id: EntityId, + provider: &P, +) -> Result +where + P: EntityProvider + OntologyTypeProvider + Sync, +{ + let entity = provider + .provide_entity(entity_id) + .await + .change_context(EntityRetrieval { entity_id }) + .map_err(LinkedEntityError::EntityRetrieval)?; + + let entity_types = stream::iter(&entity.borrow().metadata.entity_type_ids) + .then(|entity_type_url| { + provider + .provide_type(entity_type_url) + .change_context_lazy(|| EntityTypeRetrieval { + entity_type_url: entity_type_url.clone(), + }) + }) + .map_ok(|entity_type| entity_type.borrow().clone()) + .try_collect_reports::>() + .await + .map_err(LinkedEntityError::EntityTypeRetrieval)?; - status.finish() - } + ClosedMultiEntityType::from_multi_type_closed_schema(entity_types) + .map_err(LinkedEntityError::ResolveClosedEntityType) } -impl

Schema for ClosedMultiEntityType +impl

Validate for LinkData where - P: EntityProvider + EntityTypeProvider + Sync, + P: EntityProvider + OntologyTypeProvider + Sync, { - type Error = EntityValidationError; + type Report = LinkDataValidationReport; // TODO: validate link data // see https://linear.app/hash/issue/H-972 // TODO: Optimize reading of left/right parent types and/or cache them - #[expect(clippy::too_many_lines)] - async fn validate_value<'a>( - &'a self, - value: &'a LinkData, + async fn validate( + &self, + schema: &ClosedMultiEntityType, _: ValidateEntityComponents, - provider: &'a P, - ) -> Result<(), Report<[EntityValidationError]>> { - let mut status = ReportSink::new(); + context: &P, + ) -> Self::Report { + let mut validation_report = LinkDataValidationReport::default(); - let left_entity = provider - .provide_entity(value.left_entity_id) + let left_entity_type = read_entity_type(self.left_entity_id, context) .await - .change_context_lazy(|| EntityValidationError::EntityRetrieval { - id: value.left_entity_id, - })?; - - let left_entity_type = Self::from_multi_type_closed_schema( - stream::iter(&left_entity.borrow().metadata.entity_type_ids) - .then(|entity_type_url| async { - provider - .provide_type(entity_type_url) - .await - .map(|entity_type| entity_type.borrow().clone()) - }) - .try_collect::>() - .await - .change_context_lazy(|| EntityValidationError::EntityRetrieval { - id: value.left_entity_id, - })?, - ) - .change_context_lazy(|| EntityValidationError::EntityRetrieval { - id: value.left_entity_id, - })?; - - let right_entity = provider - .provide_entity(value.right_entity_id) + .map_err(|link_data_error| { + validation_report.left_entity = Some(link_data_error); + }) + .ok(); + let right_entity_type = read_entity_type(self.right_entity_id, context) .await - .change_context_lazy(|| EntityValidationError::EntityRetrieval { - id: value.right_entity_id, - })?; - - let right_entity_type = Self::from_multi_type_closed_schema( - stream::iter(&right_entity.borrow().metadata.entity_type_ids) - .then(|entity_type_url| async { - provider - .provide_type(entity_type_url) - .await - .map(|entity_type| entity_type.borrow().clone()) - }) - .try_collect::>() - .await - .change_context_lazy(|| EntityValidationError::EntityRetrieval { - id: value.right_entity_id, - })?, - ) - .change_context_lazy(|| EntityValidationError::EntityRetrieval { - id: value.right_entity_id, - })?; + .map_err(|link_data_error| { + validation_report.right_entity = Some(link_data_error); + }) + .ok(); + + // We cannot further validate the links if the left type is not known + let Some(left_entity_type) = left_entity_type else { + return validation_report; + }; // We track that at least one link type was found to avoid reporting an error if no // link type was found. - let mut found_link_target = false; - let entity_type_ids = self + let mut found_link_match = false; + let entity_type_ids = schema .all_of .iter() - .map(|entity_type| entity_type.id.clone()) - .collect::>(); - let parent_entity_type_ids = provider - .find_parents(&entity_type_ids) - .await - .change_context_lazy(|| EntityValidationError::EntityTypeRetrieval { - ids: entity_type_ids.iter().cloned().collect(), - })?; - for link_type_id in entity_type_ids.into_iter().chain(parent_entity_type_ids) { - let Some(maybe_allowed_targets) = left_entity_type.constraints.links.get(&link_type_id) + .flat_map(|entity_type| &entity_type.all_of) + .map(|entity_type| &entity_type.id) + .collect::>(); + + for link_type_id in &entity_type_ids { + let Some(maybe_allowed_targets) = left_entity_type.constraints.links.get(link_type_id) else { continue; }; - // At least one link type was found - found_link_target = true; + // At least one correct link type was found + found_link_match = true; let Some(allowed_targets) = &maybe_allowed_targets.items else { // For a given target there was an unconstrained link destination, so we can @@ -263,57 +229,58 @@ where break; }; + // We cannot further validate the links if the right type is not known. We also found a + // link match already, so we can `break` instead of `continue`. + let Some(right_entity_type) = &right_entity_type else { + break; + }; + // Link destinations are constrained, search for the right entity's type let mut found_match = false; - 'targets: for allowed_target in &allowed_targets.possibilities { - if right_entity_type - .all_of - .iter() - .any(|entity_type| entity_type.id.base_url == allowed_target.url.base_url) - { + for allowed_target in &allowed_targets.possibilities { + if right_entity_type.all_of.iter().any(|entity_type| { + // We check that the base URL matches for the exact type or the versioned URL + // for the parent types + entity_type.id.base_url == allowed_target.url.base_url + || entity_type + .all_of + .iter() + .any(|entity_type| entity_type.id == allowed_target.url) + }) { found_match = true; break; } - for right_entity_type in &right_entity_type.all_of { - if provider - .is_super_type_of(&allowed_target.url, &right_entity_type.id) - .await - .change_context_lazy(|| EntityValidationError::EntityTypeRetrieval { - ids: HashSet::from([ - right_entity_type.id.clone(), - allowed_target.url.clone(), - ]), - })? - { - found_match = true; - break 'targets; - } - } } if found_match { break; } - status.capture(EntityValidationError::InvalidLinkTargetId { - target_types: right_entity_type - .all_of - .iter() - .map(|entity_type| entity_type.id.clone()) - .collect(), + validation_report.target_type = Some(LinkTargetError::UnexpectedEntityType { + data: UnexpectedEntityType { + actual: right_entity_type + .all_of + .iter() + .map(|entity_type| entity_type.id.clone()) + .collect(), + expected: allowed_targets + .possibilities + .iter() + .map(|entity_type| entity_type.url.clone()) + .collect(), + }, }); } - if !found_link_target { - status.capture(EntityValidationError::InvalidLinkTypeId { - link_types: self - .all_of - .iter() - .map(|entity_type| entity_type.id.clone()) - .collect(), + if !found_link_match { + validation_report.link_type = Some(LinkError::UnexpectedEntityType { + data: UnexpectedEntityType { + actual: entity_type_ids.into_iter().cloned().collect(), + expected: left_entity_type.constraints.links.keys().cloned().collect(), + }, }); } - status.finish() + validation_report } } @@ -624,7 +591,7 @@ impl EntityVisitor for EntityPreprocessor { ) -> Result<(), Report<[TraversalError]>> where T: PropertyValueSchema + Sync, - P: DataTypeLookup + PropertyTypeProvider + Sync, + P: DataTypeLookup + OntologyTypeProvider + Sync, { let mut status = ReportSink::new(); if let Err(error) = walk_array(self, schema, array, type_provider).await { @@ -662,7 +629,7 @@ impl EntityVisitor for EntityPreprocessor { ) -> Result<(), Report<[TraversalError]>> where T: PropertyObjectSchema> + Sync, - P: DataTypeLookup + PropertyTypeProvider + Sync, + P: DataTypeLookup + OntologyTypeProvider + Sync, { let mut status = ReportSink::new(); if let Err(error) = walk_object(self, schema, object, type_provider).await { diff --git a/libs/@local/graph/validation/src/lib.rs b/libs/@local/graph/validation/src/lib.rs index 54abf47f9bc..d5d7053b749 100644 --- a/libs/@local/graph/validation/src/lib.rs +++ b/libs/@local/graph/validation/src/lib.rs @@ -16,26 +16,15 @@ use error_stack::Report; use hash_graph_store::entity::ValidateEntityComponents; use hash_graph_types::knowledge::entity::{Entity, EntityId}; -pub trait Schema { - type Error: Error + Send + Sync + 'static; - - fn validate_value<'a>( - &'a self, - value: &'a V, - components: ValidateEntityComponents, - provider: &'a P, - ) -> impl Future>> + Send + 'a; -} - pub trait Validate { - type Error: Error + Send + Sync + 'static; + type Report: Send + Sync; fn validate( &self, schema: &S, components: ValidateEntityComponents, context: &C, - ) -> impl Future>> + Send; + ) -> impl Future + Send; } pub trait EntityProvider { @@ -70,10 +59,9 @@ mod tests { visitor::{EntityVisitor as _, TraversalError}, }, ontology::{ - DataTypeLookup, DataTypeMetadata, DataTypeWithMetadata, EntityTypeProvider, - OntologyEditionProvenance, OntologyProvenance, OntologyTemporalMetadata, - OntologyTypeClassificationMetadata, OntologyTypeProvider, OntologyTypeRecordId, - PropertyTypeProvider, ProvidedOntologyEditionProvenance, + DataTypeLookup, DataTypeMetadata, DataTypeWithMetadata, OntologyEditionProvenance, + OntologyProvenance, OntologyTemporalMetadata, OntologyTypeClassificationMetadata, + OntologyTypeProvider, OntologyTypeRecordId, ProvidedOntologyEditionProvenance, }, owned_by_id::OwnedById, }; @@ -191,27 +179,6 @@ mod tests { } } - impl EntityTypeProvider for Provider { - #[expect(refining_impl_trait)] - async fn is_super_type_of( - &self, - _: &VersionedUrl, - _: &VersionedUrl, - ) -> Result> { - // Not used in tests - Ok(false) - } - - #[expect(refining_impl_trait)] - async fn find_parents( - &self, - _: &[VersionedUrl], - ) -> Result, Report> { - // Not used in tests - Ok(Vec::new()) - } - } - impl OntologyTypeProvider for Provider { type Value = Arc; @@ -250,8 +217,6 @@ mod tests { } } - impl PropertyTypeProvider for Provider {} - impl DataTypeLookup for Provider { type ClosedDataType = Arc; type DataTypeWithMetadata = Arc; diff --git a/libs/@local/graph/validation/src/property.rs b/libs/@local/graph/validation/src/property.rs index 672222b4293..f3b29147f43 100644 --- a/libs/@local/graph/validation/src/property.rs +++ b/libs/@local/graph/validation/src/property.rs @@ -1,30 +1,26 @@ -use error_stack::{Report, ReportSink}; -use hash_graph_store::entity::ValidateEntityComponents; +use hash_graph_store::entity::{PropertyMetadataValidationReport, ValidateEntityComponents}; use hash_graph_types::knowledge::property::{PropertyMetadataObject, PropertyObject}; -use crate::{EntityValidationError, Validate}; +use crate::Validate; impl

Validate for PropertyMetadataObject where P: Sync, { - type Error = EntityValidationError; + type Report = PropertyMetadataValidationReport; async fn validate( &self, _object: &PropertyObject, _components: ValidateEntityComponents, _provider: &P, - ) -> Result<(), Report<[Self::Error]>> { - let status = ReportSink::new(); - + ) -> Self::Report { // TODO: Validate metadata // - Check that all metadata keys are valid see: // - see: https://linear.app/hash/issue/H-2799/validate-entity-property-metadata-layout // - Check that all metadata values are valid // - see: https://linear.app/hash/issue/H-2800/validate-that-allowed-data-types-are-either-unambiguous-or-a-data-type // - see: https://linear.app/hash/issue/H-2801/validate-data-type-in-entity-property-metadata - - status.finish() + PropertyMetadataValidationReport {} } } diff --git a/libs/chonky/README.md b/libs/chonky/README.md index 2d4ec027f9f..9c252960ad8 100644 --- a/libs/chonky/README.md +++ b/libs/chonky/README.md @@ -42,7 +42,7 @@ rm -rf $temp_dir To link the library dynamically, don't enable the `static`. The binary will read `PDFIUM_DYNAMIC_LIB_PATH` to search for the library. If the variable is not set it will use `libs/`: ```sh -export PDFIUM_DYNAMIC_LIB_PATH="${pwd}/libs/" +export PDFIUM_DYNAMIC_LIB_PATH="$(pwd)/libs/" cargo build ``` @@ -61,7 +61,7 @@ rm -rf $temp_dir To link the library statically, enable the `static` feature by passing `--features static` to any `cargo` invocation. When building the library it will search for `PDFIUM_STATIC_LIB_PATH`. For example if the library is located at `libs/libpdfium.a` you can build the library with: ```sh -export PDFIUM_STATIC_LIB_PATH="${pwd}/libs/" +export PDFIUM_STATIC_LIB_PATH="$(pwd)/libs/" cargo build --features static ``` diff --git a/tests/graph/integration/postgres/lib.rs b/tests/graph/integration/postgres/lib.rs index 45e6ba483b2..ae2832d888f 100644 --- a/tests/graph/integration/postgres/lib.rs +++ b/tests/graph/integration/postgres/lib.rs @@ -53,9 +53,9 @@ use hash_graph_store::{ UpdateDataTypesParams, }, entity::{ - CountEntitiesParams, CreateEntityParams, EntityStore, GetEntitiesParams, - GetEntitiesResponse, GetEntitySubgraphParams, GetEntitySubgraphResponse, PatchEntityParams, - UpdateEntityEmbeddingsParams, ValidateEntityError, ValidateEntityParams, + CountEntitiesParams, CreateEntityParams, EntityStore, EntityValidationReport, + GetEntitiesParams, GetEntitiesResponse, GetEntitySubgraphParams, GetEntitySubgraphResponse, + PatchEntityParams, UpdateEntityEmbeddingsParams, ValidateEntityParams, }, entity_type::{ ArchiveEntityTypeParams, CountEntityTypesParams, CreateEntityTypeParams, EntityTypeStore, @@ -708,7 +708,7 @@ where actor_id: AccountId, consistency: Consistency<'_>, params: Vec>, - ) -> Result<(), Report> { + ) -> HashMap { self.store .validate_entities(actor_id, consistency, params) .await