diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/query.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/query.rs index 5c90d345712..763f0cb2923 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/query.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/query.rs @@ -1,6 +1,7 @@ use hash_graph_store::{ entity::EntityQueryPath, - subgraph::edges::{EdgeDirection, KnowledgeGraphEdgeKind}, + entity_type::EntityTypeQueryPath, + subgraph::edges::{EdgeDirection, KnowledgeGraphEdgeKind, SharedEdgeKind}, }; use serde::Deserialize as _; use tokio_postgres::Row; @@ -227,8 +228,11 @@ impl PostgresRecord for Entity { ), edition_id: compiler.add_selection_path(&EntityQueryPath::EditionId), - type_versioned_urls_id: compiler - .add_selection_path(&EntityQueryPath::TypeVersionedUrls), + type_versioned_urls_id: compiler.add_selection_path(&EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::VersionedUrl, + inheritance_depth: None, + }), direct_type_count_id: compiler.add_selection_path(&EntityQueryPath::DirectTypeCount), properties: compiler.add_selection_path(&EntityQueryPath::Properties(None)), diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/summary.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/summary.rs index 101899db0ae..0c26593fdc8 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/summary.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/summary.rs @@ -9,7 +9,9 @@ use std::collections::HashMap; use error_stack::{Report, ResultExt as _}; use hash_graph_store::{ entity::{EntityQueryPath, QueryEntitiesParams}, + entity_type::EntityTypeQueryPath, error::QueryError, + subgraph::edges::SharedEdgeKind, }; use tokio_postgres::Row; use type_system::{ @@ -112,7 +114,11 @@ impl EntitySummaryQuery { .then(|| compiler.add_selection_path(&EntityQueryPath::EditionProvenance(None))), type_columns: (params.include_type_ids || params.include_type_titles).then(|| { ( - compiler.add_selection_path(&EntityQueryPath::TypeVersionedUrls), + compiler.add_selection_path(&EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::VersionedUrl, + inheritance_depth: None, + }), compiler.add_selection_path(&EntityQueryPath::DirectTypeCount), ) }), diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/compile.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/compile.rs index d6e4b0121ef..1bbea98235f 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/compile.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/compile.rs @@ -16,7 +16,7 @@ use postgres_types::ToSql; use tracing::instrument; use type_system::knowledge::Entity; -use super::expression::{JoinType, TableName, TableReference}; +use super::expression::{ColumnReference, JoinType, TableName, TableReference}; use crate::store::postgres::query::{ Alias, Column, Distinctness, EqualityOperator, Expression, Function, Identifier, PostgresQueryPath, PostgresRecord, SelectExpression, SelectStatement, Table, Transpile as _, @@ -92,6 +92,8 @@ pub enum SelectCompilerError { UnsupportedDistanceExpression, #[display("Cannot add a cursor: {reason}")] CursorDisallowed { reason: &'static str }, + #[display("String operations are not supported on paths backed by materialized array columns")] + UnsupportedTextArrayOperation, } impl<'p, 'q: 'p, R: PostgresRecord> SelectCompiler<'p, 'q, R> { @@ -429,18 +431,8 @@ impl<'p, 'q: 'p, R: PostgresRecord> SelectCompiler<'p, 'q, R> { } Ok(match filter { - Filter::All(filters) => Expression::all( - filters - .iter() - .map(|filter| self.compile_filter(filter)) - .collect::>()?, - ), - Filter::Any(filters) => Expression::any( - filters - .iter() - .map(|filter| self.compile_filter(filter)) - .collect::>()?, - ), + Filter::All(filters) => Expression::all(self.compile_filter_group(filters, true)?), + Filter::Any(filters) => Expression::any(self.compile_filter_group(filters, false)?), Filter::Not(filter) => self.compile_filter(filter)?.not(), Filter::Equal(lhs, rhs) => Expression::equal( self.compile_filter_expression(lhs).0, @@ -644,6 +636,8 @@ impl<'p, 'q: 'p, R: PostgresRecord> SelectCompiler<'p, 'q, R> { self.compile_filter_expression_list(rhs).0, ), Filter::StartsWith(lhs, rhs) => { + Self::ensure_scalar_text_operand(lhs)?; + Self::ensure_scalar_text_operand(rhs)?; let (left_filter, left_parameter) = self.compile_filter_expression(lhs); let left_filter = if left_parameter == ParameterType::Any { Expression::Function(Function::JsonExtractText(Box::new(left_filter))) @@ -661,6 +655,8 @@ impl<'p, 'q: 'p, R: PostgresRecord> SelectCompiler<'p, 'q, R> { Expression::starts_with(left_filter, right_filter) } Filter::EndsWith(lhs, rhs) => { + Self::ensure_scalar_text_operand(lhs)?; + Self::ensure_scalar_text_operand(rhs)?; let (left_filter, left_parameter) = self.compile_filter_expression(lhs); let left_filter = if left_parameter == ParameterType::Any { Expression::Function(Function::JsonExtractText(Box::new(left_filter))) @@ -678,6 +674,8 @@ impl<'p, 'q: 'p, R: PostgresRecord> SelectCompiler<'p, 'q, R> { Expression::ends_with(left_filter, right_filter) } Filter::ContainsSegment(lhs, rhs) => { + Self::ensure_scalar_text_operand(lhs)?; + Self::ensure_scalar_text_operand(rhs)?; let (left_filter, left_parameter) = self.compile_filter_expression(lhs); let left_filter = if left_parameter == ParameterType::Any { Expression::Function(Function::JsonExtractText(Box::new(left_filter))) @@ -697,6 +695,202 @@ impl<'p, 'q: 'p, R: PostgresRecord> SelectCompiler<'p, 'q, R> { }) } + /// Rejects operands on paths terminating in materialized text-array columns. + /// + /// Equality filters on such paths compile to array predicates, but string operations + /// have no scalar column to operate on. + fn ensure_scalar_text_operand<'f: 'q>( + operand: &FilterExpression<'f, R>, + ) -> Result<(), Report> + where + R::QueryPath<'f>: PostgresQueryPath, + { + if let FilterExpression::Path { path } = operand { + let (column, json_field) = path.terminating_column(); + ensure!( + json_field.is_some() || !Self::is_text_array_column(column), + SelectCompilerError::UnsupportedTextArrayOperation + ); + } + Ok(()) + } + + /// Whether the column holds an array of textual values ([`BaseUrl`] and + /// [`VersionedUrl`] columns transpile to `text[]`). + /// + /// [`BaseUrl`]: ParameterType::BaseUrl + /// [`VersionedUrl`]: ParameterType::VersionedUrl + fn is_text_array_column(column: Column) -> bool { + matches!( + column.parameter_type(), + ParameterType::Vector(inner) if matches!( + *inner, + ParameterType::Text | ParameterType::BaseUrl | ParameterType::VersionedUrl + ) + ) + } + + /// Decomposes an equality (`Equal`/`NotEqual`) or membership (`In(parameter, path)`) + /// filter on a path terminating in a materialized text-array column. + /// + /// Returns the path, the text parameter, and whether the filter tests for containment + /// (`true`) or its absence (`false`). + fn cached_array_equality<'f: 'q>( + filter: &'p Filter<'f, R>, + ) -> Option<(&'p R::QueryPath<'f>, &'p Parameter<'f>, bool)> + where + R::QueryPath<'f>: PostgresQueryPath, + { + let (lhs, rhs, equals) = match filter { + Filter::Equal(lhs, rhs) => (lhs, rhs, true), + Filter::NotEqual(lhs, rhs) => (lhs, rhs, false), + Filter::In( + FilterExpression::Parameter { + parameter: parameter @ Parameter::Text(_), + convert: None, + }, + FilterExpressionList::Path { path }, + ) => { + let (column, json_field) = path.terminating_column(); + return (json_field.is_none() && Self::is_text_array_column(column)) + .then_some((path, parameter, true)); + } + Filter::All(_) + | Filter::Any(_) + | Filter::Not(_) + | Filter::Exists { .. } + | Filter::Greater(..) + | Filter::GreaterOrEqual(..) + | Filter::Less(..) + | Filter::LessOrEqual(..) + | Filter::CosineDistance(..) + | Filter::In(..) + | Filter::StartsWith(..) + | Filter::EndsWith(..) + | Filter::ContainsSegment(..) => return None, + }; + match (lhs, rhs) { + ( + FilterExpression::Path { path }, + FilterExpression::Parameter { + parameter: parameter @ Parameter::Text(_), + convert: None, + }, + ) + | ( + FilterExpression::Parameter { + parameter: parameter @ Parameter::Text(_), + convert: None, + }, + FilterExpression::Path { path }, + ) => { + let (column, json_field) = path.terminating_column(); + (json_field.is_none() && Self::is_text_array_column(column)) + .then_some((path, parameter, equals)) + } + _ => None, + } + } + + /// Compiles equality filters on a path backed by a materialized array column into a + /// single array predicate on that column. + /// + /// A single parameter compiles to a containment check (` @> ARRAY[$n]::text[]`, + /// negated for inequalities). Multiple parameters gathered from one `All`/`Any` group + /// bundle into one predicate over the whole value set: + /// + /// | group | equalities | inequalities | + /// |-------|---------------------|---------------------------| + /// | `All` | `@>` (contains all) | `NOT(&&)` (contains none) | + /// | `Any` | `&&` (contains any) | `NOT(@>)` (misses one) | + /// + /// A single array predicate replaces per-value joins through the type tables and lets + /// a GIN index on the materialized column serve the positive forms. + fn compile_cached_array_predicate<'f: 'q>( + &mut self, + column: ColumnReference<'static>, + parameters: &[&'p Parameter<'f>], + equals: bool, + conjunction: bool, + ) -> Expression + where + R::QueryPath<'f>: PostgresQueryPath, + { + let column_reference = Expression::ColumnReference(column); + let array = Expression::Function(Function::ArrayLiteral { + elements: parameters + .iter() + .map(|parameter| self.compile_parameter(parameter).0) + .collect(), + element_type: PostgresType::Text, + }); + // For a single value `@>` and `&&` coincide; normalize to the containment form. + let conjunction = if parameters.len() == 1 { + equals + } else { + conjunction + }; + match (equals, conjunction) { + (true, true) => Expression::array_contains(column_reference, array), + (true, false) => Expression::overlap(column_reference, array), + (false, true) => Expression::overlap(column_reference, array).not(), + (false, false) => Expression::array_contains(column_reference, array).not(), + } + } + + /// Compiles the filters of an `All`/`Any` group, bundling equality filters backed by + /// the same materialized array column into a single array predicate. + /// + /// Bundles are keyed on the *aliased* column: paths terminating in the same column + /// through different join chains (e.g. an entity's own types vs. a linked entity's + /// types) resolve to different aliases and stay separate predicates. + fn compile_filter_group<'f: 'q>( + &mut self, + filters: &'p [Filter<'f, R>], + conjunction: bool, + ) -> Result, Report> + where + R::QueryPath<'f>: PostgresQueryPath, + { + struct ArrayPredicateGroup<'c, 'p> { + column: ColumnReference<'static>, + equals: bool, + parameters: Vec<&'c Parameter<'p>>, + } + + let mut groups: Vec> = Vec::new(); + let mut expressions = Vec::new(); + for filter in filters { + if let Some((array_path, parameter, equals)) = Self::cached_array_equality(filter) { + let alias = self.add_join_statements(array_path); + let column = array_path.terminating_column().0.aliased(alias); + if let Some(group) = groups + .iter_mut() + .find(|group| group.column == column && group.equals == equals) + { + group.parameters.push(parameter); + } else { + groups.push(ArrayPredicateGroup { + column, + equals, + parameters: vec![parameter], + }); + } + } else { + expressions.push(self.compile_filter(filter)?); + } + } + for group in &groups { + expressions.push(self.compile_cached_array_predicate( + group.column.clone(), + &group.parameters, + group.equals, + conjunction, + )); + } + Ok(expressions) + } + /// Compiles the `path` to a condition, which is searching for the latest version. // Warning: This adds a CTE to the statement, which is overwriting the `ontology_ids` table. // When more CTEs are needed, a test should be added to cover both CTEs in one @@ -770,10 +964,18 @@ impl<'p, 'q: 'p, R: PostgresRecord> SelectCompiler<'p, 'q, R> { /// /// The following [`Filter`]s will be special cased: /// - Comparing the `"version"` field on [`Table::OntologyIds`] with `"latest"` for equality. + /// - Equality and membership filters on paths terminating in materialized text-array columns, + /// compiled to array predicates (see [`Self::compile_cached_array_predicate`]). fn compile_special_filter<'f: 'q>(&mut self, filter: &'p Filter<'f, R>) -> Option where R::QueryPath<'f>: PostgresQueryPath, { + if let Some((array_path, parameter, equals)) = Self::cached_array_equality(filter) { + let alias = self.add_join_statements(array_path); + let column = array_path.terminating_column().0.aliased(alias); + return Some(self.compile_cached_array_predicate(column, &[parameter], equals, true)); + } + match filter { Filter::Equal(lhs, rhs) | Filter::NotEqual(lhs, rhs) => match (lhs, rhs) { ( diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/entity.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/entity.rs index 88a505db9f2..862f925cef5 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/entity.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/entity.rs @@ -40,7 +40,12 @@ impl PostgresQueryPath for EntityQueryPath<'_> { | Self::PropertyMetadata(_) => { vec![Relation::EntityEditions] } - Self::TypeBaseUrls | Self::TypeVersionedUrls | Self::DirectTypeCount => { + Self::DirectTypeCount + | Self::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::BaseUrl | EntityTypeQueryPath::VersionedUrl, + inheritance_depth: None, + } => { vec![Relation::EntityEditionCache] } Self::EntityTypeEdge { @@ -122,11 +127,19 @@ impl PostgresQueryPath for EntityQueryPath<'_> { ), Self::Archived => (Column::EntityEditions(EntityEditions::Archived), None), Self::Embedding => (Column::EntityEmbeddings(EntityEmbeddings::Embedding), None), - Self::TypeBaseUrls => ( + Self::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::BaseUrl, + inheritance_depth: None, + } => ( Column::EntityEditionCache(EntityEditionCache::BaseUrls), None, ), - Self::TypeVersionedUrls => ( + Self::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::VersionedUrl, + inheritance_depth: None, + } => ( Column::EntityEditionCache(EntityEditionCache::VersionedUrls), None, ), diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/expression/binary.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/expression/binary.rs index 1b71edeeda4..ba329db6349 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/expression/binary.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/expression/binary.rs @@ -51,6 +51,8 @@ pub enum BinaryOperator { // --- Domain-specific --- /// ` @> ::TIMESTAMPTZ` TimeIntervalContainsTimestamp, + /// ` @> ` + ArrayContains, /// ` && ` Overlap, /// ` <=> ` @@ -76,7 +78,7 @@ impl BinaryOperator { Self::BitwiseOr => " | ", Self::JsonAccess => " -> ", Self::JsonAccessAsText => " ->> ", - Self::TimeIntervalContainsTimestamp => " @> ", + Self::TimeIntervalContainsTimestamp | Self::ArrayContains => " @> ", Self::Overlap => " && ", Self::CosineDistance => " <=> ", }; @@ -102,6 +104,7 @@ impl BinaryOperator { | Self::BitwiseOr | Self::JsonAccess | Self::JsonAccessAsText + | Self::ArrayContains | Self::Overlap | Self::CosineDistance => Ok(()), } diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs index 03f219c0e82..68ca07a0f3b 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs @@ -501,6 +501,15 @@ impl Expression { }) } + #[must_use] + pub fn array_contains(lhs: Self, rhs: Self) -> Self { + Self::Binary(BinaryExpression { + op: BinaryOperator::ArrayContains, + left: Box::new(lhs), + right: Box::new(rhs), + }) + } + #[must_use] pub fn cosine_distance(lhs: Self, rhs: Self) -> Self { Self::Binary(BinaryExpression { diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/statement/select.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/statement/select.rs index e20be475622..d156405e6e2 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/statement/select.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/statement/select.rs @@ -95,6 +95,7 @@ impl Transpile for SelectStatement { #[cfg(test)] mod tests { use alloc::borrow::Cow; + use core::str::FromStr as _; use hash_codec::numeric::Real; use hash_graph_store::{ @@ -118,6 +119,7 @@ mod tests { knowledge::Entity, ontology::{ BaseUrl, DataTypeWithMetadata, EntityTypeWithMetadata, PropertyTypeWithMetadata, + VersionedUrl, }, }; use uuid::Uuid; @@ -1241,6 +1243,324 @@ mod tests { ); } + #[test] + fn filter_entity_by_type_versioned_url() { + let temporal_axes = QueryTemporalAxesUnresolved::default().resolve(); + let pinned_timestamp = temporal_axes.pinned_timestamp(); + let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); + + let url = VersionedUrl::from_str( + "https://example.com/@example-org/types/entity-type/address/v/1", + ) + .expect("should parse versioned url"); + let filter = Filter::for_entity_by_type_id(&url); + compiler.add_filter(&filter).expect("Failed to add filter"); + + test_compilation( + &compiler, + r#" + SELECT * + FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" + INNER JOIN "entity_edition_cache" AS "entity_edition_cache_0_1_0" + ON "entity_edition_cache_0_1_0"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" + WHERE "entity_temporal_metadata_0_0_0"."draft_id" IS NULL + AND "entity_temporal_metadata_0_0_0"."transaction_time" @> $1::TIMESTAMPTZ + AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 + AND "entity_edition_cache_0_1_0"."versioned_urls" @> ARRAY[$3]::text[] + "#, + &[ + &pinned_timestamp, + &temporal_axes.variable_interval(), + &"https://example.com/@example-org/types/entity-type/address/v/1", + ], + ); + } + + #[test] + fn filter_entity_by_any_type_versioned_url() { + let temporal_axes = QueryTemporalAxesUnresolved::default().resolve(); + let pinned_timestamp = temporal_axes.pinned_timestamp(); + let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); + + let url_a = VersionedUrl::from_str( + "https://example.com/@example-org/types/entity-type/address/v/1", + ) + .expect("should parse versioned url"); + let url_b = VersionedUrl::from_str( + "https://example.com/@example-org/types/entity-type/location/v/1", + ) + .expect("should parse versioned url"); + let filter = Filter::Any(vec![ + Filter::for_entity_by_type_id(&url_a), + Filter::for_entity_by_type_id(&url_b), + ]); + compiler.add_filter(&filter).expect("Failed to add filter"); + + test_compilation( + &compiler, + r#" + SELECT * + FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" + INNER JOIN "entity_edition_cache" AS "entity_edition_cache_0_1_0" + ON "entity_edition_cache_0_1_0"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" + WHERE "entity_temporal_metadata_0_0_0"."draft_id" IS NULL + AND "entity_temporal_metadata_0_0_0"."transaction_time" @> $1::TIMESTAMPTZ + AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 + AND ("entity_edition_cache_0_1_0"."versioned_urls" && ARRAY[$3, $4]::text[]) + "#, + &[ + &pinned_timestamp, + &temporal_axes.variable_interval(), + &"https://example.com/@example-org/types/entity-type/address/v/1", + &"https://example.com/@example-org/types/entity-type/location/v/1", + ], + ); + } + + #[test] + fn filter_entity_by_all_type_versioned_url() { + let temporal_axes = QueryTemporalAxesUnresolved::default().resolve(); + let pinned_timestamp = temporal_axes.pinned_timestamp(); + let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); + + let url_a = VersionedUrl::from_str( + "https://example.com/@example-org/types/entity-type/address/v/1", + ) + .expect("should parse versioned url"); + let url_b = VersionedUrl::from_str( + "https://example.com/@example-org/types/entity-type/location/v/1", + ) + .expect("should parse versioned url"); + let filter = Filter::All(vec![ + Filter::for_entity_by_type_id(&url_a), + Filter::for_entity_by_type_id(&url_b), + ]); + compiler.add_filter(&filter).expect("Failed to add filter"); + + test_compilation( + &compiler, + r#" + SELECT * + FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" + INNER JOIN "entity_edition_cache" AS "entity_edition_cache_0_1_0" + ON "entity_edition_cache_0_1_0"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" + WHERE "entity_temporal_metadata_0_0_0"."draft_id" IS NULL + AND "entity_temporal_metadata_0_0_0"."transaction_time" @> $1::TIMESTAMPTZ + AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 + AND ("entity_edition_cache_0_1_0"."versioned_urls" @> ARRAY[$3, $4]::text[]) + "#, + &[ + &pinned_timestamp, + &temporal_axes.variable_interval(), + &"https://example.com/@example-org/types/entity-type/address/v/1", + &"https://example.com/@example-org/types/entity-type/location/v/1", + ], + ); + } + + #[test] + fn filter_entity_own_and_linked_type_stay_separate() { + let temporal_axes = QueryTemporalAxesUnresolved::default().resolve(); + let pinned_timestamp = temporal_axes.pinned_timestamp(); + let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); + + // Both paths terminate in `entity_edition_cache.versioned_urls`, but through + // different join chains — bundling them would test the linked entity's type + // against the entity's own type array. + let filter = Filter::All(vec![ + Filter::::Equal( + FilterExpression::Path { + path: EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::VersionedUrl, + inheritance_depth: None, + }, + }, + FilterExpression::Parameter { + parameter: Parameter::Text(Cow::Borrowed( + "https://example.com/@example-org/types/entity-type/page/v/1", + )), + convert: None, + }, + ), + Filter::::Equal( + FilterExpression::Path { + path: EntityQueryPath::EntityEdge { + edge_kind: KnowledgeGraphEdgeKind::HasRightEntity, + path: Box::new(EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::VersionedUrl, + inheritance_depth: None, + }), + direction: EdgeDirection::Outgoing, + }, + }, + FilterExpression::Parameter { + parameter: Parameter::Text(Cow::Borrowed( + "https://example.com/@example-org/types/entity-type/user/v/1", + )), + convert: None, + }, + ), + ]); + compiler.add_filter(&filter).expect("Failed to add filter"); + + test_compilation( + &compiler, + r#" + SELECT * + FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" + INNER JOIN "entity_edition_cache" AS "entity_edition_cache_0_1_0" + ON "entity_edition_cache_0_1_0"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" + LEFT OUTER JOIN "entity_has_right_entity" AS "entity_has_right_entity_0_1_0" + ON "entity_has_right_entity_0_1_0"."web_id" = "entity_temporal_metadata_0_0_0"."web_id" + AND "entity_has_right_entity_0_1_0"."entity_uuid" = "entity_temporal_metadata_0_0_0"."entity_uuid" + LEFT OUTER JOIN "entity_temporal_metadata" AS "entity_temporal_metadata_0_2_0" + ON "entity_temporal_metadata_0_2_0"."web_id" = "entity_has_right_entity_0_1_0"."right_web_id" + AND "entity_temporal_metadata_0_2_0"."entity_uuid" = "entity_has_right_entity_0_1_0"."right_entity_uuid" + AND "entity_temporal_metadata_0_2_0"."draft_id" IS NULL + AND "entity_temporal_metadata_0_2_0"."transaction_time" @> $1::TIMESTAMPTZ + AND "entity_temporal_metadata_0_2_0"."decision_time" && $2 + LEFT OUTER JOIN "entity_edition_cache" AS "entity_edition_cache_0_3_0" + ON "entity_edition_cache_0_3_0"."entity_edition_id" = "entity_temporal_metadata_0_2_0"."entity_edition_id" + WHERE "entity_temporal_metadata_0_0_0"."draft_id" IS NULL + AND "entity_temporal_metadata_0_0_0"."transaction_time" @> $1::TIMESTAMPTZ + AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 + AND ("entity_edition_cache_0_1_0"."versioned_urls" @> ARRAY[$3]::text[]) + AND ("entity_edition_cache_0_3_0"."versioned_urls" @> ARRAY[$4]::text[]) + "#, + &[ + &pinned_timestamp, + &temporal_axes.variable_interval(), + &"https://example.com/@example-org/types/entity-type/page/v/1", + &"https://example.com/@example-org/types/entity-type/user/v/1", + ], + ); + } + + #[test] + fn filter_entity_by_no_type_versioned_url() { + let temporal_axes = QueryTemporalAxesUnresolved::default().resolve(); + let pinned_timestamp = temporal_axes.pinned_timestamp(); + let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); + + let exclusion = |url: &'static str| { + Filter::::NotEqual( + FilterExpression::Path { + path: EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::VersionedUrl, + inheritance_depth: None, + }, + }, + FilterExpression::Parameter { + parameter: Parameter::Text(Cow::Borrowed(url)), + convert: None, + }, + ) + }; + let filter = Filter::All(vec![ + exclusion("https://example.com/@example-org/types/entity-type/address/v/1"), + exclusion("https://example.com/@example-org/types/entity-type/location/v/1"), + ]); + compiler.add_filter(&filter).expect("Failed to add filter"); + + test_compilation( + &compiler, + r#" + SELECT * + FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" + INNER JOIN "entity_edition_cache" AS "entity_edition_cache_0_1_0" + ON "entity_edition_cache_0_1_0"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" + WHERE "entity_temporal_metadata_0_0_0"."draft_id" IS NULL + AND "entity_temporal_metadata_0_0_0"."transaction_time" @> $1::TIMESTAMPTZ + AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 + AND (NOT("entity_edition_cache_0_1_0"."versioned_urls" && ARRAY[$3, $4]::text[])) + "#, + &[ + &pinned_timestamp, + &temporal_axes.variable_interval(), + &"https://example.com/@example-org/types/entity-type/address/v/1", + &"https://example.com/@example-org/types/entity-type/location/v/1", + ], + ); + } + + #[test] + fn filter_entity_by_type_versioned_url_not_equal() { + let temporal_axes = QueryTemporalAxesUnresolved::default().resolve(); + let pinned_timestamp = temporal_axes.pinned_timestamp(); + let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); + + let filter = Filter::::NotEqual( + FilterExpression::Path { + path: EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::VersionedUrl, + inheritance_depth: None, + }, + }, + FilterExpression::Parameter { + parameter: Parameter::Text(Cow::Borrowed( + "https://example.com/@example-org/types/entity-type/address/v/1", + )), + convert: None, + }, + ); + compiler.add_filter(&filter).expect("Failed to add filter"); + + test_compilation( + &compiler, + r#" + SELECT * + FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" + INNER JOIN "entity_edition_cache" AS "entity_edition_cache_0_1_0" + ON "entity_edition_cache_0_1_0"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" + WHERE "entity_temporal_metadata_0_0_0"."draft_id" IS NULL + AND "entity_temporal_metadata_0_0_0"."transaction_time" @> $1::TIMESTAMPTZ + AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 + AND NOT("entity_edition_cache_0_1_0"."versioned_urls" @> ARRAY[$3]::text[]) + "#, + &[ + &pinned_timestamp, + &temporal_axes.variable_interval(), + &"https://example.com/@example-org/types/entity-type/address/v/1", + ], + ); + } + + #[test] + fn filter_entity_by_type_base_url() { + let temporal_axes = QueryTemporalAxesUnresolved::default().resolve(); + let pinned_timestamp = temporal_axes.pinned_timestamp(); + let mut compiler = SelectCompiler::::with_asterisk(Some(&temporal_axes), false); + + let base_url = + BaseUrl::new("https://example.com/@example-org/types/entity-type/address/".to_owned()) + .expect("should parse base url"); + let filter = Filter::for_entity_by_base_type_id(&base_url); + compiler.add_filter(&filter).expect("Failed to add filter"); + + test_compilation( + &compiler, + r#" + SELECT * + FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" + INNER JOIN "entity_edition_cache" AS "entity_edition_cache_0_1_0" + ON "entity_edition_cache_0_1_0"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" + WHERE "entity_temporal_metadata_0_0_0"."draft_id" IS NULL + AND "entity_temporal_metadata_0_0_0"."transaction_time" @> $1::TIMESTAMPTZ + AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 + AND "entity_edition_cache_0_1_0"."base_urls" @> ARRAY[$3]::text[] + "#, + &[ + &pinned_timestamp, + &temporal_axes.variable_interval(), + &"https://example.com/@example-org/types/entity-type/address/", + ], + ); + } + #[test] fn filter_embedding_distance() { let mut compiler = SelectCompiler::::with_asterisk(None, false); @@ -1425,7 +1745,7 @@ mod tests { let mut compiler = SelectCompiler::::new(None, false); - // with_property_masking automatically adds the TypeBaseUrls join + // with_property_masking automatically adds the entity_edition_cache join let property_filter = config.to_property_protection_filter(None); compiler.with_property_masking(&property_filter); @@ -1435,8 +1755,8 @@ mod tests { &compiler, r#" SELECT ("entity_editions_0_1_0"."properties" - (CASE WHEN - ($1 = ANY("entity_edition_cache_0_1_0"."base_urls")) - AND ("entity_temporal_metadata_0_0_0"."entity_uuid" != $2) + ("entity_temporal_metadata_0_0_0"."entity_uuid" != $1) + AND ("entity_edition_cache_0_1_0"."base_urls" @> ARRAY[$2]::text[]) THEN ARRAY[$3]::text[] ELSE ARRAY[]::text[] END)) FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" @@ -1449,8 +1769,8 @@ mod tests { WHERE "entity_temporal_metadata_0_0_0"."draft_id" IS NULL "#, &[ - &"https://hash.ai/@h/types/entity-type/user/", &Uuid::nil(), + &"https://hash.ai/@h/types/entity-type/user/", &"https://hash.ai/@h/types/property-type/email/", ], ); diff --git a/libs/@local/graph/store/src/entity/query.rs b/libs/@local/graph/store/src/entity/query.rs index 3e64a3c3376..7122bcadfc3 100644 --- a/libs/@local/graph/store/src/entity/query.rs +++ b/libs/@local/graph/store/src/entity/query.rs @@ -101,31 +101,13 @@ pub enum EntityQueryPath<'p> { /// [`EntityMetadata`]: type_system::knowledge::entity::EntityMetadata /// [`EntityTemporalMetadata`]: type_system::knowledge::entity::metadata::EntityTemporalMetadata TransactionTime, - /// The list of [`EntityType`]s' [`BaseUrl`]s belonging to the [`Entity`]. - /// - /// It's currently not possible to query for the list of types directly. Use [`EntityTypeEdge`] - /// instead. - /// - /// [`Entity`]: type_system::knowledge::Entity - /// [`BaseUrl`]: type_system::ontology::BaseUrl - /// [`EntityType`]: type_system::ontology::entity_type::EntityType - /// [`EntityTypeEdge`]: Self::EntityTypeEdge - TypeBaseUrls, - /// The list of [`EntityType`]s' versions belonging to the [`Entity`]. - /// - /// It's currently not possible to query for the list of types directly. Use [`EntityTypeEdge`] - /// instead. - /// - /// [`Entity`]: type_system::knowledge::Entity - /// [`EntityType`]: type_system::ontology::entity_type::EntityType - /// [`EntityTypeEdge`]: Self::EntityTypeEdge - TypeVersionedUrls, /// The number of direct (non-inherited) types of the [`Entity`]. /// - /// The type arrays in the edition cache list direct types first, so this is the length - /// of the direct-type prefix. + /// Type lists selected via [`EntityTypeEdge`] (without an inheritance depth) order + /// direct types first, so this is the length of the direct-type prefix. /// /// [`Entity`]: type_system::knowledge::Entity + /// [`EntityTypeEdge`]: Self::EntityTypeEdge DirectTypeCount, /// The confidence value for the [`Entity`]. /// @@ -491,8 +473,6 @@ impl fmt::Display for EntityQueryPath<'_> { Self::EditionId => fmt.write_str("editionId"), Self::DecisionTime => fmt.write_str("decisionTime"), Self::TransactionTime => fmt.write_str("transactionTime"), - Self::TypeBaseUrls => fmt.write_str("typeBaseUrls"), - Self::TypeVersionedUrls => fmt.write_str("typeVersionedUrls"), Self::DirectTypeCount => fmt.write_str("directTypeCount"), Self::Archived => fmt.write_str("archived"), Self::Properties(Some(property)) => write!(fmt, "properties.{property}"), @@ -551,9 +531,6 @@ impl QueryPath for EntityQueryPath<'_> { match self { Self::EditionId | Self::Uuid | Self::WebId | Self::DraftId => ParameterType::Uuid, Self::DecisionTime | Self::TransactionTime => ParameterType::TimeInterval, - Self::TypeBaseUrls | Self::TypeVersionedUrls => { - ParameterType::Vector(Box::new(ParameterType::VersionedUrl)) - } Self::DirectTypeCount => ParameterType::Integer, Self::Properties(_) | Self::Label { .. } @@ -909,8 +886,6 @@ impl<'de: 'p, 'p> EntityQueryPath<'p> { Self::EditionId => EntityQueryPath::EditionId, Self::DecisionTime => EntityQueryPath::DecisionTime, Self::TransactionTime => EntityQueryPath::TransactionTime, - Self::TypeBaseUrls => EntityQueryPath::TypeBaseUrls, - Self::TypeVersionedUrls => EntityQueryPath::TypeVersionedUrls, Self::DirectTypeCount => EntityQueryPath::DirectTypeCount, Self::Archived => EntityQueryPath::Archived, Self::EntityTypeEdge { diff --git a/libs/@local/graph/store/src/filter/mod.rs b/libs/@local/graph/store/src/filter/mod.rs index 1ef993d9049..dd547e67ff0 100644 --- a/libs/@local/graph/store/src/filter/mod.rs +++ b/libs/@local/graph/store/src/filter/mod.rs @@ -905,24 +905,19 @@ impl<'p> Filter<'p, Entity> { #[must_use] pub fn for_entity_by_type_id(entity_type_id: &'p VersionedUrl) -> Self { - Filter::All(vec![ - Self::for_entity_by_base_type_id(&entity_type_id.base_url), - Filter::Equal( - FilterExpression::Path { - path: EntityQueryPath::EntityTypeEdge { - edge_kind: SharedEdgeKind::IsOfType, - path: EntityTypeQueryPath::Version, - inheritance_depth: None, - }, - }, - FilterExpression::Parameter { - parameter: Parameter::OntologyTypeVersion(Cow::Borrowed( - &entity_type_id.version, - )), - convert: None, + Filter::Equal( + FilterExpression::Path { + path: EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::VersionedUrl, + inheritance_depth: None, }, - ), - ]) + }, + FilterExpression::Parameter { + parameter: Parameter::Text(Cow::Owned(entity_type_id.to_string())), + convert: None, + }, + ) } #[must_use] diff --git a/libs/@local/graph/store/src/filter/protection.rs b/libs/@local/graph/store/src/filter/protection.rs index 4226c5e4d94..9b18758e71a 100644 --- a/libs/@local/graph/store/src/filter/protection.rs +++ b/libs/@local/graph/store/src/filter/protection.rs @@ -555,7 +555,9 @@ use type_system::{ use crate::{ entity::EntityQueryPath, + entity_type::EntityTypeQueryPath, filter::{Filter, FilterExpression, JsonPath, Parameter, PathToken}, + subgraph::edges::SharedEdgeKind, }; #[derive(Debug, Clone, PartialEq)] @@ -640,7 +642,11 @@ impl<'p> PropertyProtectionFilterConfig<'p> { )), }, PropertyFilterExpressionList::Path { - path: EntityQueryPath::TypeBaseUrls, + path: EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::BaseUrl, + inheritance_depth: None, + }, }, ), PropertyFilter::NotEqual( @@ -857,8 +863,6 @@ fn collect_from_path<'f, 'p, I: Extend<&'f PropertyFilter<'p>>>( | EntityQueryPath::EditionId | EntityQueryPath::DecisionTime | EntityQueryPath::TransactionTime - | EntityQueryPath::TypeBaseUrls - | EntityQueryPath::TypeVersionedUrls | EntityQueryPath::DirectTypeCount | EntityQueryPath::EntityConfidence | EntityQueryPath::LeftEntityConfidence @@ -1414,7 +1418,7 @@ mod tests { use super::*; - /// Creates `User IN TypeBaseUrls` filter (entity has User type). + /// Creates a `User IN type base URLs` filter (entity has User type). fn type_is_user() -> Filter<'static, Entity> { Filter::In( FilterExpression::Parameter { @@ -1422,7 +1426,11 @@ mod tests { convert: None, }, FilterExpressionList::Path { - path: EntityQueryPath::TypeBaseUrls, + path: EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::BaseUrl, + inheritance_depth: None, + }, }, ) } diff --git a/tests/graph/integration/postgres/email_filter_protection.rs b/tests/graph/integration/postgres/email_filter_protection.rs index 2d22cf1038f..f65cc1db839 100644 --- a/tests/graph/integration/postgres/email_filter_protection.rs +++ b/tests/graph/integration/postgres/email_filter_protection.rs @@ -26,6 +26,7 @@ use hash_graph_store::{ CountEntitiesParams, CreateEntityParams, EntityQueryPath, EntityQuerySorting, EntityQuerySortingRecord, EntityStore as _, QueryEntitiesParams, QueryEntitySubgraphParams, }, + entity_type::EntityTypeQueryPath, filter::{ Filter, FilterExpression, JsonPath, Parameter, PathToken, protection::{ @@ -35,7 +36,10 @@ use hash_graph_store::{ }, query::{NullOrdering, Ordering}, subgraph::{ - edges::{EdgeDirection, EntityTraversalEdge, EntityTraversalPath, GraphResolveDepths}, + edges::{ + EdgeDirection, EntityTraversalEdge, EntityTraversalPath, GraphResolveDepths, + SharedEdgeKind, + }, temporal_axes::{ PinnedTemporalAxisUnresolved, QueryTemporalAxesUnresolved, VariableTemporalAxisUnresolved, @@ -1428,7 +1432,11 @@ fn multi_property_config() -> PropertyProtectionFilterConfig<'static> { parameter: Parameter::Text(Cow::Borrowed(USER_ENTITY_TYPE_BASE_URL)), }, PropertyFilterExpressionList::Path { - path: EntityQueryPath::TypeBaseUrls, + path: EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::BaseUrl, + inheritance_depth: None, + }, }, ), PropertyFilter::NotEqual( @@ -2050,7 +2058,11 @@ fn multi_type_config() -> PropertyProtectionFilterConfig<'static> { parameter: Parameter::Text(Cow::Borrowed(USER_ENTITY_TYPE_BASE_URL)), }, PropertyFilterExpressionList::Path { - path: EntityQueryPath::TypeBaseUrls, + path: EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::BaseUrl, + inheritance_depth: None, + }, }, ), PropertyFilter::NotEqual( @@ -2069,7 +2081,11 @@ fn multi_type_config() -> PropertyProtectionFilterConfig<'static> { parameter: Parameter::Text(Cow::Borrowed(SECRET_ENTITY_TYPE_BASE_URL)), }, PropertyFilterExpressionList::Path { - path: EntityQueryPath::TypeBaseUrls, + path: EntityQueryPath::EntityTypeEdge { + edge_kind: SharedEdgeKind::IsOfType, + path: EntityTypeQueryPath::BaseUrl, + inheritance_depth: None, + }, }, ), PropertyFilter::NotEqual( diff --git a/tests/hash-backend-integration/src/tests/subgraph/friendship.test.ts b/tests/hash-backend-integration/src/tests/subgraph/friendship.test.ts index fcea7852bcb..ccfbf8eb99a 100644 --- a/tests/hash-backend-integration/src/tests/subgraph/friendship.test.ts +++ b/tests/hash-backend-integration/src/tests/subgraph/friendship.test.ts @@ -130,7 +130,7 @@ const friendshipFilter: DistributiveField< QueryEntitySubgraphRequest, "filter" > = { - startsWith: [ + equal: [ { path: ["type", "baseUrl"], },