diff --git a/libs/@local/graph/migrations/graph-migrations/v009__entities/down.sql b/libs/@local/graph/migrations/graph-migrations/v009__entities/down.sql index 9454a556cdd..8c1bbd6db73 100644 --- a/libs/@local/graph/migrations/graph-migrations/v009__entities/down.sql +++ b/libs/@local/graph/migrations/graph-migrations/v009__entities/down.sql @@ -1,13 +1,4 @@ -DROP VIEW IF EXISTS first_type_title_for_entity; -DROP VIEW IF EXISTS last_type_title_for_entity; -DROP VIEW IF EXISTS type_title_for_entity; - -DROP VIEW IF EXISTS first_label_for_entity; -DROP VIEW IF EXISTS last_label_for_entity; -DROP VIEW IF EXISTS label_for_entity; - DROP TABLE entity_embeddings; -DROP VIEW entity_is_of_type_ids; DROP TABLE entity_is_of_type; DROP VIEW entity_has_left_entity; DROP VIEW entity_has_right_entity; @@ -15,6 +6,7 @@ DROP TABLE entity_edge; DROP TYPE EDGE_DIRECTION; DROP TYPE ENTITY_EDGE_KIND; DROP TABLE entity_temporal_metadata; +DROP TABLE entity_edition_cache; DROP TABLE entity_editions; DROP TABLE entity_drafts; DROP TABLE entity_ids; diff --git a/libs/@local/graph/migrations/graph-migrations/v009__entities/up.sql b/libs/@local/graph/migrations/graph-migrations/v009__entities/up.sql index c733f058ffa..1d9fdc32f78 100644 --- a/libs/@local/graph/migrations/graph-migrations/v009__entities/up.sql +++ b/libs/@local/graph/migrations/graph-migrations/v009__entities/up.sql @@ -21,6 +21,34 @@ CREATE TABLE entity_editions ( confidence DOUBLE PRECISION ); +-- Denormalized per-edition cache of the sorting/filtering aggregates, rebuildable at +-- any time via `reindex_entity_cache`. The four type-derived arrays are positionally +-- aligned and cover ALL inheritance depths (type containment checks match supertypes), +-- ordered by (inheritance depth, title, base URL, version DESC) — the direct types form +-- the prefix of length `direct_types`, and `[1]` is the canonically first direct type. +-- `versions` carries the numeric versions for consumers needing base URL and version +-- separately (e.g. HashQL). +-- `labels` is resolved per direct type (label inheritance lives in each type's +-- `closed_schema.allOf`), ordered by (title, base URL, version DESC); `labels[1]` is +-- the display/sort label, NULL when the entity has none. Descending sorts reuse the +-- same element — no min/max flip. +CREATE TABLE entity_edition_cache ( + entity_edition_id UUID PRIMARY KEY REFERENCES entity_editions ON DELETE CASCADE, + direct_types INT NOT NULL, + labels TEXT [], + type_titles TEXT [] NOT NULL, + base_urls TEXT [] NOT NULL, + versions BIGINT [] NOT NULL, + versioned_urls TEXT [] NOT NULL +); + +-- Type filters arrive as containment checks (`@>`); labels/titles are only sorted or +-- projected, never filtered, so they carry no index. +CREATE INDEX entity_edition_cache_base_urls ON entity_edition_cache USING gin (base_urls); +CREATE INDEX entity_edition_cache_versioned_urls ON entity_edition_cache USING gin ( + versioned_urls +); + CREATE TABLE entity_temporal_metadata ( web_id UUID NOT NULL, entity_uuid UUID NOT NULL, @@ -58,16 +86,6 @@ CREATE TABLE entity_is_of_type ( PRIMARY KEY (entity_edition_id, entity_type_ontology_id) ); -CREATE VIEW entity_is_of_type_ids AS -SELECT - entity_is_of_type.entity_edition_id, - array_agg(ontology_ids.base_url) AS base_urls, - array_agg(ontology_ids.version) AS versions -FROM entity_is_of_type -INNER JOIN ontology_ids ON entity_is_of_type.entity_type_ontology_id = ontology_ids.ontology_id -WHERE entity_is_of_type.inheritance_depth = 0 -GROUP BY entity_is_of_type.entity_edition_id; - CREATE TYPE entity_edge_kind AS ENUM ('has-left-entity', 'has-right-entity'); CREATE TYPE edge_direction AS ENUM ('outgoing', 'incoming'); @@ -126,75 +144,3 @@ CREATE TABLE entity_embeddings ( CREATE UNIQUE INDEX entity_embeddings_idx ON entity_embeddings (web_id, entity_uuid, property) NULLS NOT DISTINCT; - - -CREATE VIEW type_title_for_entity AS -SELECT - entity_temporal_metadata.entity_edition_id, - entity_types.schema ->> 'title' AS title -FROM entity_temporal_metadata -INNER JOIN entity_is_of_type - ON entity_temporal_metadata.entity_edition_id = entity_is_of_type.entity_edition_id -INNER JOIN ontology_temporal_metadata - ON entity_is_of_type.entity_type_ontology_id = ontology_temporal_metadata.ontology_id -INNER JOIN entity_types - ON ontology_temporal_metadata.ontology_id = entity_types.ontology_id -WHERE ontology_temporal_metadata.transaction_time @> now() - AND entity_is_of_type.inheritance_depth = 0; - -CREATE VIEW first_type_title_for_entity AS -SELECT - type_title_for_entity.entity_edition_id, - min(type_title_for_entity.title) AS title -FROM type_title_for_entity -GROUP BY type_title_for_entity.entity_edition_id; - -CREATE VIEW last_type_title_for_entity AS -SELECT - type_title_for_entity.entity_edition_id, - max(type_title_for_entity.title) AS title -FROM type_title_for_entity -GROUP BY type_title_for_entity.entity_edition_id; - - -CREATE VIEW label_for_entity AS -SELECT - entity_editions.entity_edition_id, - jsonb_extract_path( - entity_editions.properties, - jsonb_array_elements_text( - jsonb_path_query_array( - entity_types.closed_schema, - '$.allOf[*].labelProperty' - ) - ) - ) AS label_property -FROM entity_editions -INNER JOIN entity_is_of_type - ON entity_editions.entity_edition_id = entity_is_of_type.entity_edition_id -INNER JOIN ontology_temporal_metadata - ON entity_is_of_type.entity_type_ontology_id = ontology_temporal_metadata.ontology_id -INNER JOIN entity_types - ON ontology_temporal_metadata.ontology_id = entity_types.ontology_id -WHERE ontology_temporal_metadata.transaction_time @> now() - AND entity_is_of_type.inheritance_depth = 0; - -CREATE VIEW first_label_for_entity AS -SELECT - label_for_entity.entity_edition_id, - (array_agg( - label_for_entity.label_property - ORDER BY label_for_entity.label_property ASC - ))[1] AS label_property -FROM label_for_entity -GROUP BY label_for_entity.entity_edition_id; - -CREATE VIEW last_label_for_entity AS -SELECT - label_for_entity.entity_edition_id, - (array_agg( - label_for_entity.label_property - ORDER BY label_for_entity.label_property DESC - ))[1] AS label_property -FROM label_for_entity -GROUP BY label_for_entity.entity_edition_id; diff --git a/libs/@local/graph/postgres-store/postgres_migrations/V51__entity_edition_cache.sql b/libs/@local/graph/postgres-store/postgres_migrations/V51__entity_edition_cache.sql new file mode 100644 index 00000000000..6d3a6a7524e --- /dev/null +++ b/libs/@local/graph/postgres-store/postgres_migrations/V51__entity_edition_cache.sql @@ -0,0 +1,122 @@ +-- Denormalized per-edition cache of an entity's type/label aggregates. Computing these +-- inline forces the planner to re-evaluate the aggregation per row in entity-subgraph +-- queries; reading the cache instead is a single 1:1 join. +-- +-- The cache is derived data: rows are inserted alongside `entity_is_of_type` writes and +-- can be fully rebuilt at any time (`reindex_entity_cache`), e.g. after in-place changes +-- to entity-type schemas. A row exists exactly for editions that have at least one +-- depth-0 entity type. +-- +-- The four type-derived arrays (`type_titles`, `base_urls`, `versions`, +-- `versioned_urls`) are +-- positionally aligned and cover ALL inheritance depths so that type predicates +-- (containment via `@>`) match supertypes. Order is (inheritance depth, title, +-- base URL, version DESC); the entity's direct types therefore form the array prefix +-- of length `direct_types` (used to project `entityTypeIds`), and `[1]` is the +-- canonically first direct type, providing the type-title sort key. Titles are taken +-- from `entity_types` without the `transaction_time @> now()` filter, so the cache +-- does not depend on type archival state. +-- +-- `labels` is resolved per DIRECT type (label inheritance already lives in each type's +-- `closed_schema.allOf`, nearest ancestor first), ordered by the canonical type order +-- (title, base URL, version DESC) with the `allOf` position as tie-breaker within one +-- type. `labels[1]` is the entity's display/sort label; NULL means the entity has no +-- label. Descending sorts use the same element — there is no min/max flip. +CREATE TABLE entity_edition_cache ( + entity_edition_id UUID PRIMARY KEY REFERENCES entity_editions ON DELETE CASCADE, + direct_types INT NOT NULL, + labels TEXT [], + type_titles TEXT [] NOT NULL, + base_urls TEXT [] NOT NULL, + versions BIGINT [] NOT NULL, + versioned_urls TEXT [] NOT NULL +); + +INSERT INTO entity_edition_cache ( + entity_edition_id, + direct_types, + labels, + type_titles, + base_urls, + versions, + versioned_urls +) +SELECT + types.entity_edition_id, + types.direct_types, + labels.labels, + types.type_titles, + types.base_urls, + types.versions, + types.versioned_urls +FROM ( + SELECT + entity_is_of_type.entity_edition_id, + count(*) FILTER (WHERE entity_is_of_type.inheritance_depth = 0) AS direct_types, + array_agg(entity_types.schema ->> 'title' + ORDER BY entity_is_of_type.inheritance_depth, + entity_types.schema ->> 'title', ontology_ids.base_url, + ontology_ids.version DESC + ) AS type_titles, + array_agg(ontology_ids.base_url + ORDER BY entity_is_of_type.inheritance_depth, + entity_types.schema ->> 'title', ontology_ids.base_url, + ontology_ids.version DESC + ) AS base_urls, + array_agg(ontology_ids.version + ORDER BY entity_is_of_type.inheritance_depth, + entity_types.schema ->> 'title', ontology_ids.base_url, + ontology_ids.version DESC + ) AS versions, + array_agg(ontology_ids.base_url || 'v/' || ontology_ids.version + ORDER BY entity_is_of_type.inheritance_depth, + entity_types.schema ->> 'title', ontology_ids.base_url, + ontology_ids.version DESC + ) AS versioned_urls + FROM entity_is_of_type + INNER JOIN ontology_ids + ON entity_is_of_type.entity_type_ontology_id = ontology_ids.ontology_id + INNER JOIN entity_types + ON ontology_ids.ontology_id = entity_types.ontology_id + GROUP BY entity_is_of_type.entity_edition_id +) AS types +LEFT JOIN ( + SELECT + entity_is_of_type.entity_edition_id, + array_agg(label_value.label + ORDER BY entity_types.schema ->> 'title', ontology_ids.base_url, + ontology_ids.version DESC, label_value.ordinality + ) FILTER (WHERE label_value.label IS NOT NULL) AS labels + FROM entity_is_of_type + INNER JOIN ontology_ids + ON entity_is_of_type.entity_type_ontology_id = ontology_ids.ontology_id + INNER JOIN entity_types + ON ontology_ids.ontology_id = entity_types.ontology_id + INNER JOIN entity_editions + ON entity_is_of_type.entity_edition_id = entity_editions.entity_edition_id + CROSS JOIN LATERAL ( + SELECT + jsonb_extract_path(entity_editions.properties, label_path.path) #>> '{}' AS label, + label_path.ordinality + FROM jsonb_array_elements_text(jsonb_path_query_array(entity_types.closed_schema, '$.allOf[*].labelProperty')) + WITH ORDINALITY AS label_path (path, ordinality) + ) AS label_value + WHERE entity_is_of_type.inheritance_depth = 0 + GROUP BY entity_is_of_type.entity_edition_id +) AS labels + ON types.entity_edition_id = labels.entity_edition_id; + +-- Type filters arrive as containment checks (`@>`); labels/titles are only sorted or +-- projected, never filtered, so they carry no index. +CREATE INDEX entity_edition_cache_base_urls ON entity_edition_cache USING gin (base_urls); +CREATE INDEX entity_edition_cache_versioned_urls ON entity_edition_cache USING gin (versioned_urls); + +-- The cache replaces the per-row aggregate views for every consumer (query compiler and +-- HashQL), so they are dropped. +DROP VIEW first_label_for_entity; +DROP VIEW last_label_for_entity; +DROP VIEW label_for_entity; +DROP VIEW first_type_title_for_entity; +DROP VIEW last_type_title_for_entity; +DROP VIEW type_title_for_entity; +DROP VIEW entity_is_of_type_ids; 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 c2fadd9d4fe..0d09b9ef1a2 100644 --- a/libs/@local/graph/postgres-store/src/snapshot/entity/batch.rs +++ b/libs/@local/graph/postgres-store/src/snapshot/entity/batch.rs @@ -435,6 +435,15 @@ where .await .change_context(InsertionError)?; + // The cache derives `labels` from `entity_editions.properties`, so the normalized + // values written above would otherwise leave stale label entries behind. + if !edition_ids_updates.is_empty() { + postgres_client + .reindex_entity_cache() + .await + .change_context(InsertionError)?; + } + Ok(()) } } 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 1a9e44593f5..62445cd833a 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 @@ -81,7 +81,7 @@ use type_system::{ entity_type::{ ClosedEntityType, ClosedMultiEntityType, EntityTypeUuid, EntityTypeWithMetadata, }, - id::{BaseUrl, OntologyTypeUuid, OntologyTypeVersion, VersionedUrl}, + id::{OntologyTypeUuid, VersionedUrl}, }, principal::{actor::ActorEntityUuid, actor_group::WebId}, }; @@ -545,8 +545,8 @@ where let type_ids_idx = (params.include_type_ids || params.include_type_titles).then(|| { ( - compiler.add_selection_path(&EntityQueryPath::TypeBaseUrls), - compiler.add_selection_path(&EntityQueryPath::TypeVersions), + compiler.add_selection_path(&EntityQueryPath::TypeVersionedUrls), + compiler.add_selection_path(&EntityQueryPath::DirectTypeCount), ) }); @@ -610,16 +610,16 @@ where edition_created_by_ids.extend_one(provenance.created_by_id); } - if let Some((type_ids, (base_urls_idx, versions_idx))) = + if let Some((type_ids, (versioned_urls_idx, direct_count_idx))) = type_ids.as_mut().zip(type_ids_idx) { - let base_urls: Vec = row.get(base_urls_idx); - let versions: Vec = row.get(versions_idx); + let direct_type_count = + usize::try_from(row.get::<_, i32>(direct_count_idx)) + .expect("direct type count should be non-negative"); type_ids.extend( - base_urls + row.get::<_, Vec>(versioned_urls_idx) .into_iter() - .zip(versions) - .map(|(base_url, version)| VersionedUrl { base_url, version }), + .take(direct_type_count), ); } }) @@ -1233,6 +1233,21 @@ where .await .change_context(InsertionError)?; + transaction + .as_client() + .query( + &insert_entity_edition_cache_statement(true), + &[&entity_edition_ids], + ) + .instrument(tracing::info_span!( + "INSERT", + otel.kind = "client", + db.system = "postgresql", + peer.service = "Postgres", + )) + .await + .change_context(InsertionError)?; + for (index, (entity, (schema, components))) in entities.iter().zip(validation_params).enumerate() { @@ -2486,6 +2501,8 @@ where JOIN entity_type_inherits_from ON entity_type_ontology_id = source_entity_type_ontology_id GROUP BY entity_edition_id, target_entity_type_ontology_id; + + DELETE FROM entity_edition_cache; ", ) .instrument(tracing::info_span!( @@ -2497,6 +2514,18 @@ where .await .change_context(UpdateError)?; + transaction + .as_client() + .query(&insert_entity_edition_cache_statement(false), &[]) + .instrument(tracing::info_span!( + "INSERT", + otel.kind = "client", + db.system = "postgresql", + peer.service = "Postgres", + )) + .await + .change_context(UpdateError)?; + transaction.commit().await.change_context(UpdateError)?; Ok(()) @@ -2594,6 +2623,108 @@ struct LockedEntityEdition { transaction_time: LeftClosedTemporalInterval, } +/// Builds the statement populating `entity_edition_cache` by aggregating the editions' +/// `entity_is_of_type` rows joined to the referenced types. +/// +/// The write paths pass `scoped` to restrict it to the just-written editions +/// (`$1: UUID[]`), `reindex_entity_cache` runs it unscoped over all editions. Must run +/// after the editions' `entity_is_of_type` rows (including the inherited ones) have been +/// written. +fn insert_entity_edition_cache_statement(scoped: bool) -> String { + let types_scope = if scoped { + "WHERE entity_is_of_type.entity_edition_id = ANY($1)" + } else { + "" + }; + let labels_scope = if scoped { + "AND entity_is_of_type.entity_edition_id = ANY($1)" + } else { + "" + }; + format!( + " + INSERT INTO entity_edition_cache ( + entity_edition_id, + direct_types, + labels, + type_titles, + base_urls, + versions, + versioned_urls + ) + SELECT types.entity_edition_id, + types.direct_types, + labels.labels, + types.type_titles, + types.base_urls, + types.versions, + types.versioned_urls + FROM ( + SELECT entity_is_of_type.entity_edition_id, + count(*) FILTER ( + WHERE entity_is_of_type.inheritance_depth = 0 + ) AS direct_types, + array_agg(entity_types.schema ->> 'title' + ORDER BY entity_is_of_type.inheritance_depth, + entity_types.schema ->> 'title', ontology_ids.base_url, + ontology_ids.version DESC + ) AS type_titles, + array_agg(ontology_ids.base_url + ORDER BY entity_is_of_type.inheritance_depth, + entity_types.schema ->> 'title', ontology_ids.base_url, + ontology_ids.version DESC + ) AS base_urls, + array_agg(ontology_ids.version + ORDER BY entity_is_of_type.inheritance_depth, + entity_types.schema ->> 'title', ontology_ids.base_url, + ontology_ids.version DESC + ) AS versions, + array_agg(ontology_ids.base_url || 'v/' || ontology_ids.version + ORDER BY entity_is_of_type.inheritance_depth, + entity_types.schema ->> 'title', ontology_ids.base_url, + ontology_ids.version DESC + ) AS versioned_urls + FROM entity_is_of_type + JOIN ontology_ids + ON entity_is_of_type.entity_type_ontology_id = ontology_ids.ontology_id + JOIN entity_types + ON ontology_ids.ontology_id = entity_types.ontology_id + {types_scope} + GROUP BY entity_is_of_type.entity_edition_id + ) AS types + LEFT JOIN ( + SELECT entity_is_of_type.entity_edition_id, + array_agg(label_value.label + ORDER BY entity_types.schema ->> 'title', ontology_ids.base_url, + ontology_ids.version DESC, label_value.ordinality + ) FILTER (WHERE label_value.label IS NOT NULL) AS labels + FROM entity_is_of_type + JOIN ontology_ids + ON entity_is_of_type.entity_type_ontology_id = ontology_ids.ontology_id + JOIN entity_types + ON ontology_ids.ontology_id = entity_types.ontology_id + JOIN entity_editions + ON entity_is_of_type.entity_edition_id = entity_editions.entity_edition_id + CROSS JOIN LATERAL ( + SELECT jsonb_extract_path( + entity_editions.properties, label_path.path + ) #>> '{{}}' AS label, + label_path.ordinality + FROM jsonb_array_elements_text( + jsonb_path_query_array( + entity_types.closed_schema, '$.allOf[*].labelProperty' + ) + ) WITH ORDINALITY AS label_path (path, ordinality) + ) AS label_value + WHERE entity_is_of_type.inheritance_depth = 0 + {labels_scope} + GROUP BY entity_is_of_type.entity_edition_id + ) AS labels + ON types.entity_edition_id = labels.entity_edition_id; +" + ) +} + impl PostgresStore> { #[tracing::instrument(level = "info", skip_all)] async fn insert_entity_edition( @@ -2679,6 +2810,21 @@ impl PostgresStore> { .await .change_context(InsertionError)?; + let edition_ids = [edition_id]; + self.as_client() + .query( + &insert_entity_edition_cache_statement(true), + &[&edition_ids.as_slice()], + ) + .instrument(tracing::info_span!( + "INSERT", + otel.kind = "client", + db.system = "postgresql", + peer.service = "Postgres", + )) + .await + .change_context(InsertionError)?; + Ok(edition_id) } @@ -3143,3 +3289,19 @@ impl PostgresStore> { }) } } + +#[cfg(test)] +mod tests { + use super::insert_entity_edition_cache_statement; + + #[test] + fn cache_statement_scoping() { + let scoped = insert_entity_edition_cache_statement(true); + let unscoped = insert_entity_edition_cache_statement(false); + + assert_eq!(scoped.matches("= ANY($1)").count(), 2); + assert_eq!(unscoped.matches("= ANY($1)").count(), 0); + // the jsonb text-extraction operator must survive the `format!` brace escaping + assert!(scoped.contains("#>> '{}'")); + } +} 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 68c64854ea3..5c90d345712 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 @@ -14,7 +14,7 @@ use type_system::{ }, property::metadata::PropertyObjectMetadata, }, - ontology::id::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + ontology::id::VersionedUrl, }; use crate::store::postgres::{ @@ -30,8 +30,8 @@ pub struct EntityRecordRowIndices { pub decision_time: usize, pub edition_id: usize, - pub type_base_urls_id: usize, - pub type_versions_id: usize, + pub type_versioned_urls_id: usize, + pub direct_type_count_id: usize, pub properties: usize, @@ -162,12 +162,15 @@ impl QueryRecordDecode for Entity { decision_time: row.get(indices.decision_time), transaction_time: row.get(indices.transaction_time), }, - entity_type_ids: row - .get::<_, Vec>(indices.type_base_urls_id) - .into_iter() - .zip(row.get::<_, Vec>(indices.type_versions_id)) - .map(|(base_url, version)| VersionedUrl { base_url, version }) - .collect(), + entity_type_ids: { + let direct_type_count = + usize::try_from(row.get::<_, i32>(indices.direct_type_count_id)) + .expect("direct type count should be non-negative"); + row.get::<_, Vec>(indices.type_versioned_urls_id) + .into_iter() + .take(direct_type_count) + .collect() + }, provenance: EntityProvenance { inferred: row.get(indices.provenance), edition: row.get(indices.edition_provenance), @@ -224,8 +227,9 @@ impl PostgresRecord for Entity { ), edition_id: compiler.add_selection_path(&EntityQueryPath::EditionId), - type_base_urls_id: compiler.add_selection_path(&EntityQueryPath::TypeBaseUrls), - type_versions_id: compiler.add_selection_path(&EntityQueryPath::TypeVersions), + type_versioned_urls_id: compiler + .add_selection_path(&EntityQueryPath::TypeVersionedUrls), + 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/ontology/entity_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs index 414281ccf09..1ebd862f40f 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs @@ -9,7 +9,7 @@ use hash_graph_authorization::policies::{ action::ActionName, principal::actor::AuthenticatedActor, }; use hash_graph_store::{ - entity::ClosedMultiEntityTypeMap, + entity::{ClosedMultiEntityTypeMap, EntityStore}, entity_type::{ ArchiveEntityTypeParams, ClosedDataTypeDefinition, CommonQueryEntityTypesParams, CountEntityTypesParams, CreateEntityTypeParams, EntityTypeQueryPath, @@ -1873,7 +1873,7 @@ where #[tracing::instrument(level = "info", skip(self))] async fn reindex_entity_type_cache(&mut self) -> Result<(), Report> { tracing::info!("Reindexing entity type cache"); - let transaction = self.transaction().await.change_context(UpdateError)?; + let mut transaction = self.transaction().await.change_context(UpdateError)?; // We remove the data from the reference tables first transaction @@ -1950,6 +1950,11 @@ where .change_context(UpdateError)?; } + // The entity edition cache derives type titles, labels (via `closed_schema`), and + // the inherited type entries from the data rebuilt above, so it has to be rebuilt + // as well — otherwise it silently keeps serving the pre-reindex schemas. + EntityStore::reindex_entity_cache(&mut transaction).await?; + transaction.commit().await.change_context(UpdateError)?; Ok(()) 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 a8ea98f3bbf..d6e4b0121ef 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 @@ -523,16 +523,12 @@ impl<'p, 'q: 'p, R: PostgresRecord> SelectCompiler<'p, 'q, R> { | Table::DataTypeConversionAggregation | Table::PropertyTypes | Table::EntityTypes - | Table::FirstTitleForEntity - | Table::LastTitleForEntity - | Table::FirstLabelForEntity - | Table::LastLabelForEntity + | Table::EntityEditionCache | Table::EntityIds | Table::EntityDrafts | Table::EntityTemporalMetadata | Table::EntityEditions | Table::EntityIsOfType - | Table::EntityIsOfTypeIds | Table::EntityHasLeftEntity | Table::EntityHasRightEntity | Table::EntityEdge @@ -583,16 +579,12 @@ impl<'p, 'q: 'p, R: PostgresRecord> SelectCompiler<'p, 'q, R> { | Table::DataTypeConversionAggregation | Table::PropertyTypes | Table::EntityTypes - | Table::FirstTitleForEntity - | Table::LastTitleForEntity - | Table::FirstLabelForEntity - | Table::LastLabelForEntity + | Table::EntityEditionCache | Table::EntityIds | Table::EntityDrafts | Table::EntityTemporalMetadata | Table::EntityEditions | Table::EntityIsOfType - | Table::EntityIsOfTypeIds | Table::EntityHasLeftEntity | Table::EntityHasRightEntity | Table::EntityEdge @@ -884,6 +876,10 @@ impl<'p, 'q: 'p, R: PostgresRecord> SelectCompiler<'p, 'q, R> { PathToken::Field(Cow::Borrowed(field)), )) } + Some(JsonField::ArrayElement(index)) => Expression::ArrayElement { + expr: Box::new(column_expression), + index, + }, Some(JsonField::Label { inheritance_depth }) => { if let Some(label_path) = ::QueryPath::label_property_path(inheritance_depth) @@ -967,11 +963,13 @@ impl<'p, 'q: 'p, R: PostgresRecord> SelectCompiler<'p, 'q, R> { match expression { FilterExpression::Path { path } => { let (column, json_field) = path.terminating_column(); - let parameter_type = if let Some(JsonField::StaticText(_)) = json_field { - ParameterType::Text - } else { - column.parameter_type() - }; + let parameter_type = + if let Some(JsonField::StaticText(_) | JsonField::ArrayElement(_)) = json_field + { + ParameterType::Text + } else { + column.parameter_type() + }; (self.compile_path_column(path), parameter_type) } FilterExpression::Parameter { parameter, convert } => { @@ -994,11 +992,13 @@ impl<'p, 'q: 'p, R: PostgresRecord> SelectCompiler<'p, 'q, R> { match expression { FilterExpressionList::Path { path } => { let (column, json_field) = path.terminating_column(); - let parameter_type = if let Some(JsonField::StaticText(_)) = json_field { - ParameterType::Text - } else { - column.parameter_type() - }; + let parameter_type = + if let Some(JsonField::StaticText(_) | JsonField::ArrayElement(_)) = json_field + { + ParameterType::Text + } else { + column.parameter_type() + }; (self.compile_path_column(path), parameter_type) } FilterExpressionList::ParameterList { parameters } => { 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 377c76d0cd7..88a505db9f2 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 @@ -9,9 +9,9 @@ use hash_graph_store::{ use crate::store::postgres::query::{ PostgresQueryPath, table::{ - Column, EntityEditions, EntityEmbeddings, EntityHasLeftEntity, EntityHasRightEntity, - EntityIds, EntityIsOfTypeIds, EntityTemporalMetadata, JsonField, LabelForEntity, - ReferenceTable, Relation, TypeTitleForEntity, + Column, EntityEditionCache, EntityEditions, EntityEmbeddings, EntityHasLeftEntity, + EntityHasRightEntity, EntityIds, EntityTemporalMetadata, JsonField, ReferenceTable, + Relation, }, }; @@ -40,7 +40,9 @@ impl PostgresQueryPath for EntityQueryPath<'_> { | Self::PropertyMetadata(_) => { vec![Relation::EntityEditions] } - Self::TypeBaseUrls | Self::TypeVersions => vec![Relation::EntityIsOfTypes], + Self::TypeBaseUrls | Self::TypeVersionedUrls | Self::DirectTypeCount => { + vec![Relation::EntityEditionCache] + } Self::EntityTypeEdge { edge_kind: SharedEdgeKind::IsOfType, path, @@ -87,10 +89,7 @@ impl PostgresQueryPath for EntityQueryPath<'_> { }) .chain(path.relations()) .collect(), - Self::FirstTypeTitle => vec![Relation::FirstTitleForEntity], - Self::LastTypeTitle => vec![Relation::LastTitleForEntity], - Self::FirstLabel => vec![Relation::FirstLabelForEntity], - Self::LastLabel => vec![Relation::LastLabelForEntity], + Self::FirstTypeTitle | Self::FirstLabel => vec![Relation::EntityEditionCache], } } @@ -123,8 +122,18 @@ impl PostgresQueryPath for EntityQueryPath<'_> { ), Self::Archived => (Column::EntityEditions(EntityEditions::Archived), None), Self::Embedding => (Column::EntityEmbeddings(EntityEmbeddings::Embedding), None), - Self::TypeBaseUrls => (Column::EntityIsOfTypeIds(EntityIsOfTypeIds::BaseUrls), None), - Self::TypeVersions => (Column::EntityIsOfTypeIds(EntityIsOfTypeIds::Versions), None), + Self::TypeBaseUrls => ( + Column::EntityEditionCache(EntityEditionCache::BaseUrls), + None, + ), + Self::TypeVersionedUrls => ( + Column::EntityEditionCache(EntityEditionCache::VersionedUrls), + None, + ), + Self::DirectTypeCount => ( + Column::EntityEditionCache(EntityEditionCache::DirectTypes), + None, + ), Self::EntityTypeEdge { path, .. } => path.terminating_column(), Self::EntityEdge { edge_kind: KnowledgeGraphEdgeKind::HasLeftEntity, @@ -198,10 +207,14 @@ impl PostgresQueryPath for EntityQueryPath<'_> { Column::EntityHasRightEntity(EntityHasRightEntity::Provenance), None, ), - Self::FirstTypeTitle => (Column::FirstTitleForEntity(TypeTitleForEntity::Title), None), - Self::LastTypeTitle => (Column::LastTitleForEntity(TypeTitleForEntity::Title), None), - Self::FirstLabel => (Column::FirstLabelForEntity(LabelForEntity::Label), None), - Self::LastLabel => (Column::LastLabelForEntity(LabelForEntity::Label), None), + Self::FirstTypeTitle => ( + Column::EntityEditionCache(EntityEditionCache::TypeTitles), + Some(JsonField::ArrayElement(1)), + ), + Self::FirstLabel => ( + Column::EntityEditionCache(EntityEditionCache::Labels), + Some(JsonField::ArrayElement(1)), + ), } } 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 84a9379349a..03f219c0e82 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 @@ -337,6 +337,13 @@ pub enum Expression { expr: Box, field: ColumnName<'static>, }, + /// 1-based array subscript access. + /// + /// Transpiles to `()[]` in PostgreSQL. + ArrayElement { + expr: Box, + index: usize, + }, /// Row expansion - expands a composite type into its constituent columns. /// /// Transpiles to `(expression).*` in PostgreSQL, which is used to expand @@ -655,6 +662,11 @@ impl Transpile for Expression { fmt.write_str(").")?; field.transpile(fmt) } + Self::ArrayElement { expr, index } => { + fmt.write_char('(')?; + expr.transpile(fmt)?; + write!(fmt, ")[{index}]") + } Self::ColumnReference(column) => column.transpile(fmt), Self::Parameter(index) => write!(fmt, "${index}"), Self::Constant(constant) => constant.transpile(fmt), diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/mod.rs index 2b8256f562a..4135d04049e 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/mod.rs @@ -129,7 +129,16 @@ impl<'s> QueryRecordDecode for EntityQuerySorting<'s> { fn decode(row: &Row, indices: &Self::Indices) -> Self::Output { EntityQueryCursor { - values: indices.iter().map(|i| row.get(i)).collect(), + values: indices + .iter() + .map(|i| { + // Sort keys can be NULL (e.g. the label of an unlabeled entity); + // `Json(Null)` is the sentinel `compile` turns into the `IS NULL` + // cursor continuation. + row.get::<_, Option>(i) + .unwrap_or(CursorField::Json(PropertyValue::Null)) + }) + .collect(), } } } 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 c0888403e1b..e20be475622 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 @@ -1287,6 +1287,41 @@ mod tests { ); } + #[test] + fn sort_by_label_and_type_title() { + let temporal_axes = QueryTemporalAxesUnresolved::default().resolve(); + let pinned_timestamp = temporal_axes.pinned_timestamp(); + let mut compiler = SelectCompiler::::new(Some(&temporal_axes), true); + compiler.add_distinct_selection_with_ordering( + &EntityQueryPath::FirstLabel, + Distinctness::Distinct, + Some((Ordering::Ascending, Some(NullOrdering::Last))), + ); + compiler.add_distinct_selection_with_ordering( + &EntityQueryPath::FirstTypeTitle, + Distinctness::Distinct, + Some((Ordering::Descending, Some(NullOrdering::Last))), + ); + + test_compilation( + &compiler, + r#" + SELECT + DISTINCT ON(("entity_edition_cache_0_1_0"."labels")[1], ("entity_edition_cache_0_1_0"."type_titles")[1]) + ("entity_edition_cache_0_1_0"."labels")[1], + ("entity_edition_cache_0_1_0"."type_titles")[1] + 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"."transaction_time" @> $1::TIMESTAMPTZ + AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 + ORDER BY ("entity_edition_cache_0_1_0"."labels")[1] ASC NULLS LAST, + ("entity_edition_cache_0_1_0"."type_titles")[1] DESC NULLS LAST + "#, + &[&pinned_timestamp, &temporal_axes.variable_interval()], + ); + } + #[test] fn transpile_offset() { let statement = SelectStatement::builder() @@ -1400,7 +1435,7 @@ mod tests { &compiler, r#" SELECT ("entity_editions_0_1_0"."properties" - (CASE WHEN - ($1 = ANY("entity_is_of_type_ids_0_1_0"."base_urls")) + ($1 = ANY("entity_edition_cache_0_1_0"."base_urls")) AND ("entity_temporal_metadata_0_0_0"."entity_uuid" != $2) THEN ARRAY[$3]::text[] ELSE ARRAY[]::text[] END)) @@ -1408,8 +1443,8 @@ mod tests { INNER JOIN "entity_editions" AS "entity_editions_0_1_0" ON "entity_editions_0_1_0"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" - INNER JOIN "entity_is_of_type_ids" AS "entity_is_of_type_ids_0_1_0" - ON "entity_is_of_type_ids_0_1_0"."entity_edition_id" = + 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 "#, diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/table.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/table.rs index e5e76391404..98fa7a37781 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/table.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/table.rs @@ -29,18 +29,14 @@ pub enum Table { PropertyTypes, PropertyTypeEmbeddings, EntityTypes, - FirstTitleForEntity, - LastTitleForEntity, - FirstLabelForEntity, - LastLabelForEntity, EntityTypeEmbeddings, EntityIds, EntityDrafts, EntityTemporalMetadata, EntityEditions, + EntityEditionCache, EntityEmbeddings, EntityIsOfType, - EntityIsOfTypeIds, EntityHasLeftEntity, EntityHasRightEntity, EntityEdge, @@ -342,18 +338,14 @@ impl Table { Self::PropertyTypes => "property_types", Self::PropertyTypeEmbeddings => "property_type_embeddings", Self::EntityTypes => "entity_types", - Self::FirstTitleForEntity => "first_type_title_for_entity", - Self::LastTitleForEntity => "last_type_title_for_entity", - Self::FirstLabelForEntity => "first_label_for_entity", - Self::LastLabelForEntity => "last_label_for_entity", Self::EntityTypeEmbeddings => "entity_type_embeddings", Self::EntityIds => "entity_ids", Self::EntityDrafts => "entity_drafts", Self::EntityTemporalMetadata => "entity_temporal_metadata", Self::EntityEditions => "entity_editions", + Self::EntityEditionCache => "entity_edition_cache", Self::EntityEmbeddings => "entity_embeddings", Self::EntityIsOfType => "entity_is_of_type", - Self::EntityIsOfTypeIds => "entity_is_of_type_ids", Self::EntityHasLeftEntity => "entity_has_left_entity", Self::EntityHasRightEntity => "entity_has_right_entity", Self::EntityEdge => "entity_edge", @@ -373,7 +365,14 @@ pub enum JsonField<'p> { JsonPath(&'p JsonPath<'p>), JsonPathParameter(usize), StaticText(&'static str), - Label { inheritance_depth: Option }, + /// 1-based Postgres array subscript, e.g. `("table"."column")[1]`. + /// + /// Filter parameters against subscripted columns are typed as [`ParameterType::Text`], + /// so this is only valid for text arrays. + ArrayElement(usize), + Label { + inheritance_depth: Option, + }, } impl<'p> JsonField<'p> { @@ -389,6 +388,7 @@ impl<'p> JsonField<'p> { ), Self::JsonPathParameter(index) => (JsonField::JsonPathParameter(index), None), Self::StaticText(text) => (JsonField::StaticText(text), None), + Self::ArrayElement(index) => (JsonField::ArrayElement(index), None), Self::Label { inheritance_depth } => (JsonField::Label { inheritance_depth }, None), } } @@ -739,57 +739,49 @@ impl DatabaseColumn for EntityTypes { } #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -pub enum LabelForEntity { - EditionId, - Label, -} - -impl DatabaseColumn for LabelForEntity { - fn parameter_type(self) -> ParameterType { - match self { - Self::EditionId => ParameterType::Uuid, - Self::Label => ParameterType::Text, - } - } - - fn nullable(self) -> bool { - match self { - Self::EditionId | Self::Label => false, - } - } - - fn as_str(self) -> &'static str { - match self { - Self::EditionId => "entity_edition_id", - Self::Label => "label_property", - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -pub enum TypeTitleForEntity { - EditionId, - Title, +pub enum EntityEditionCache { + EntityEditionId, + DirectTypes, + Labels, + TypeTitles, + BaseUrls, + Versions, + VersionedUrls, } -impl DatabaseColumn for TypeTitleForEntity { +impl DatabaseColumn for EntityEditionCache { fn parameter_type(self) -> ParameterType { match self { - Self::EditionId => ParameterType::Uuid, - Self::Title => ParameterType::Text, + Self::EntityEditionId => ParameterType::Uuid, + Self::DirectTypes => ParameterType::Integer, + Self::Labels | Self::TypeTitles => ParameterType::Vector(Box::new(ParameterType::Text)), + Self::BaseUrls => ParameterType::Vector(Box::new(ParameterType::BaseUrl)), + Self::Versions => ParameterType::Vector(Box::new(ParameterType::OntologyTypeVersion)), + Self::VersionedUrls => ParameterType::Vector(Box::new(ParameterType::VersionedUrl)), } } fn nullable(self) -> bool { match self { - Self::EditionId | Self::Title => false, + Self::Labels => true, + Self::EntityEditionId + | Self::DirectTypes + | Self::TypeTitles + | Self::BaseUrls + | Self::Versions + | Self::VersionedUrls => false, } } fn as_str(self) -> &'static str { match self { - Self::EditionId => "entity_edition_id", - Self::Title => "title", + Self::EntityEditionId => "entity_edition_id", + Self::DirectTypes => "direct_types", + Self::Labels => "labels", + Self::TypeTitles => "type_titles", + Self::BaseUrls => "base_urls", + Self::Versions => "versions", + Self::VersionedUrls => "versioned_urls", } } } @@ -1113,35 +1105,6 @@ impl DatabaseColumn for EntityIsOfType { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -pub enum EntityIsOfTypeIds { - EntityEditionId, - BaseUrls, - Versions, -} - -impl DatabaseColumn for EntityIsOfTypeIds { - fn parameter_type(self) -> ParameterType { - match self { - Self::EntityEditionId => ParameterType::Uuid, - Self::BaseUrls => ParameterType::Vector(Box::new(ParameterType::BaseUrl)), - Self::Versions => ParameterType::Vector(Box::new(ParameterType::OntologyTypeVersion)), - } - } - - fn nullable(self) -> bool { - false - } - - fn as_str(self) -> &'static str { - match self { - Self::EntityEditionId => "entity_edition_id", - Self::BaseUrls => "base_urls", - Self::Versions => "versions", - } - } -} - #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum EntityHasLeftEntity { WebId, @@ -1420,10 +1383,7 @@ pub enum Column { EntityIds(EntityIds), EntityTemporalMetadata(EntityTemporalMetadata), EntityEditions(EntityEditions), - FirstLabelForEntity(LabelForEntity), - LastLabelForEntity(LabelForEntity), - FirstTitleForEntity(TypeTitleForEntity), - LastTitleForEntity(TypeTitleForEntity), + EntityEditionCache(EntityEditionCache), EntityEmbeddings(EntityEmbeddings), PropertyTypeConstrainsValuesOn(PropertyTypeConstrainsValuesOn), PropertyTypeConstrainsPropertiesOn(PropertyTypeConstrainsPropertiesOn), @@ -1432,7 +1392,6 @@ pub enum Column { EntityTypeConstrainsLinksOn(EntityTypeConstrainsLinksOn, Option), EntityTypeConstrainsLinkDestinationsOn(EntityTypeConstrainsLinkDestinationsOn, Option), EntityIsOfType(EntityIsOfType, Option), - EntityIsOfTypeIds(EntityIsOfTypeIds), EntityHasLeftEntity(EntityHasLeftEntity), EntityHasRightEntity(EntityHasRightEntity), } @@ -1579,9 +1538,9 @@ impl From for Column { } } -impl From for Column { - fn from(column: EntityIsOfTypeIds) -> Self { - Self::EntityIsOfTypeIds(column) +impl From for Column { + fn from(column: EntityEditionCache) -> Self { + Self::EntityEditionCache(column) } } @@ -1617,10 +1576,7 @@ impl Column { Self::EntityIds(_) => Table::EntityIds, Self::EntityTemporalMetadata(_) => Table::EntityTemporalMetadata, Self::EntityEditions(_) => Table::EntityEditions, - Self::FirstLabelForEntity(_) => Table::FirstLabelForEntity, - Self::LastLabelForEntity(_) => Table::LastLabelForEntity, - Self::FirstTitleForEntity(_) => Table::FirstTitleForEntity, - Self::LastTitleForEntity(_) => Table::LastTitleForEntity, + Self::EntityEditionCache(_) => Table::EntityEditionCache, Self::EntityEmbeddings(_) => Table::EntityEmbeddings, Self::DataTypeInheritsFrom(_, inheritance_depth) => { Table::Reference(ReferenceTable::DataTypeInheritsFrom { inheritance_depth }) @@ -1650,7 +1606,6 @@ impl Column { Self::EntityIsOfType(_, inheritance_depth) => { Table::Reference(ReferenceTable::EntityIsOfType { inheritance_depth }) } - Self::EntityIsOfTypeIds(_) => Table::EntityIsOfTypeIds, Self::EntityHasLeftEntity(_) => Table::Reference(ReferenceTable::EntityHasLeftEntity), Self::EntityHasRightEntity(_) => Table::Reference(ReferenceTable::EntityHasRightEntity), } @@ -1681,14 +1636,10 @@ impl Column { | Self::EntityIds(_) | Self::EntityTemporalMetadata(_) | Self::EntityEditions(_) - | Self::FirstLabelForEntity(_) - | Self::LastLabelForEntity(_) - | Self::FirstTitleForEntity(_) - | Self::LastTitleForEntity(_) + | Self::EntityEditionCache(_) | Self::EntityEmbeddings(_) | Self::PropertyTypeConstrainsValuesOn(_) | Self::PropertyTypeConstrainsPropertiesOn(_) - | Self::EntityIsOfTypeIds(_) | Self::EntityHasLeftEntity(_) | Self::EntityHasRightEntity(_) => None, } @@ -1715,12 +1666,7 @@ impl DatabaseColumn for Column { Self::EntityIds(column) => column.parameter_type(), Self::EntityTemporalMetadata(column) => column.parameter_type(), Self::EntityEditions(column) => column.parameter_type(), - Self::FirstLabelForEntity(column) | Self::LastLabelForEntity(column) => { - column.parameter_type() - } - Self::FirstTitleForEntity(column) | Self::LastTitleForEntity(column) => { - column.parameter_type() - } + Self::EntityEditionCache(column) => column.parameter_type(), Self::EntityEmbeddings(column) => column.parameter_type(), Self::PropertyTypeConstrainsValuesOn(column) => column.parameter_type(), Self::PropertyTypeConstrainsPropertiesOn(column) => column.parameter_type(), @@ -1729,7 +1675,6 @@ impl DatabaseColumn for Column { Self::EntityTypeConstrainsLinksOn(column, _) => column.parameter_type(), Self::EntityTypeConstrainsLinkDestinationsOn(column, _) => column.parameter_type(), Self::EntityIsOfType(column, _) => column.parameter_type(), - Self::EntityIsOfTypeIds(column) => column.parameter_type(), Self::EntityHasLeftEntity(column) => column.parameter_type(), Self::EntityHasRightEntity(column) => column.parameter_type(), } @@ -1754,12 +1699,7 @@ impl DatabaseColumn for Column { Self::EntityIds(column) => column.nullable(), Self::EntityTemporalMetadata(column) => column.nullable(), Self::EntityEditions(column) => column.nullable(), - Self::FirstLabelForEntity(column) | Self::LastLabelForEntity(column) => { - column.nullable() - } - Self::FirstTitleForEntity(column) | Self::LastTitleForEntity(column) => { - column.nullable() - } + Self::EntityEditionCache(column) => column.nullable(), Self::EntityEmbeddings(column) => column.nullable(), Self::PropertyTypeConstrainsValuesOn(column) => column.nullable(), Self::PropertyTypeConstrainsPropertiesOn(column) => column.nullable(), @@ -1768,7 +1708,6 @@ impl DatabaseColumn for Column { Self::EntityTypeConstrainsLinksOn(column, _) => column.nullable(), Self::EntityTypeConstrainsLinkDestinationsOn(column, _) => column.nullable(), Self::EntityIsOfType(column, _) => column.nullable(), - Self::EntityIsOfTypeIds(column) => column.nullable(), Self::EntityHasLeftEntity(column) => column.nullable(), Self::EntityHasRightEntity(column) => column.nullable(), } @@ -1793,8 +1732,7 @@ impl DatabaseColumn for Column { Self::EntityIds(column) => column.as_str(), Self::EntityTemporalMetadata(column) => column.as_str(), Self::EntityEditions(column) => column.as_str(), - Self::FirstLabelForEntity(column) | Self::LastLabelForEntity(column) => column.as_str(), - Self::FirstTitleForEntity(column) | Self::LastTitleForEntity(column) => column.as_str(), + Self::EntityEditionCache(column) => column.as_str(), Self::EntityEmbeddings(column) => column.as_str(), Self::PropertyTypeConstrainsValuesOn(column) => column.as_str(), Self::PropertyTypeConstrainsPropertiesOn(column) => column.as_str(), @@ -1803,7 +1741,6 @@ impl DatabaseColumn for Column { Self::EntityTypeConstrainsLinksOn(column, _) => column.as_str(), Self::EntityTypeConstrainsLinkDestinationsOn(column, _) => column.as_str(), Self::EntityIsOfType(column, _) => column.as_str(), - Self::EntityIsOfTypeIds(column) => column.as_str(), Self::EntityHasLeftEntity(column) => column.as_str(), Self::EntityHasRightEntity(column) => column.as_str(), } @@ -1856,13 +1793,9 @@ pub enum Relation { DataTypeEmbeddings, PropertyTypeIds, EntityTypeIds, - EntityIsOfTypes, EntityIds, EntityEditions, - FirstTitleForEntity, - LastTitleForEntity, - FirstLabelForEntity, - LastLabelForEntity, + EntityEditionCache, PropertyTypeEmbeddings, EntityTypeEmbeddings, EntityEmbeddings, @@ -2060,11 +1993,6 @@ impl Relation { join: Column::EntityTypes(EntityTypes::OntologyId), join_type: JoinType::Inner, }), - Self::EntityIsOfTypes => ForeignKeyJoin::from_reference(ForeignKeyReference::Single { - on: Column::EntityTemporalMetadata(EntityTemporalMetadata::EditionId), - join: Column::EntityIsOfTypeIds(EntityIsOfTypeIds::EntityEditionId), - join_type: JoinType::Inner, - }), Self::EntityTypeEmbeddings => { ForeignKeyJoin::from_reference(ForeignKeyReference::Single { on: Column::OntologyTemporalMetadata(OntologyTemporalMetadata::OntologyId), @@ -2088,34 +2016,13 @@ impl Relation { join: Column::EntityEditions(EntityEditions::EditionId), join_type: JoinType::Inner, }), - Self::FirstTitleForEntity => { - ForeignKeyJoin::from_reference(ForeignKeyReference::Single { - on: Column::EntityTemporalMetadata(EntityTemporalMetadata::EditionId), - join: Column::FirstTitleForEntity(TypeTitleForEntity::EditionId), - join_type: JoinType::Inner, - }) - } - Self::LastTitleForEntity => { + Self::EntityEditionCache => { ForeignKeyJoin::from_reference(ForeignKeyReference::Single { on: Column::EntityTemporalMetadata(EntityTemporalMetadata::EditionId), - join: Column::LastTitleForEntity(TypeTitleForEntity::EditionId), + join: Column::EntityEditionCache(EntityEditionCache::EntityEditionId), join_type: JoinType::Inner, }) } - Self::FirstLabelForEntity => { - ForeignKeyJoin::from_reference(ForeignKeyReference::Single { - on: Column::EntityTemporalMetadata(EntityTemporalMetadata::EditionId), - join: Column::FirstLabelForEntity(LabelForEntity::EditionId), - join_type: JoinType::LeftOuter, - }) - } - Self::LastLabelForEntity => { - ForeignKeyJoin::from_reference(ForeignKeyReference::Single { - on: Column::EntityTemporalMetadata(EntityTemporalMetadata::EditionId), - join: Column::LastLabelForEntity(LabelForEntity::EditionId), - join_type: JoinType::LeftOuter, - }) - } Self::EntityEmbeddings => ForeignKeyJoin::from_reference(ForeignKeyReference::Double { on: [ Column::EntityTemporalMetadata(EntityTemporalMetadata::WebId), @@ -2187,13 +2094,9 @@ impl Relation { | Self::DataTypeEmbeddings | Self::PropertyTypeIds | Self::EntityTypeIds - | Self::EntityIsOfTypes | Self::EntityIds | Self::EntityEditions - | Self::FirstTitleForEntity - | Self::LastTitleForEntity - | Self::FirstLabelForEntity - | Self::LastLabelForEntity + | Self::EntityEditionCache | Self::PropertyTypeEmbeddings | Self::EntityTypeEmbeddings | Self::EntityEmbeddings diff --git a/libs/@local/graph/store/src/entity/query.rs b/libs/@local/graph/store/src/entity/query.rs index 7f977a4aa15..3e64a3c3376 100644 --- a/libs/@local/graph/store/src/entity/query.rs +++ b/libs/@local/graph/store/src/entity/query.rs @@ -119,7 +119,14 @@ pub enum EntityQueryPath<'p> { /// [`Entity`]: type_system::knowledge::Entity /// [`EntityType`]: type_system::ontology::entity_type::EntityType /// [`EntityTypeEdge`]: Self::EntityTypeEdge - TypeVersions, + 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. + /// + /// [`Entity`]: type_system::knowledge::Entity + DirectTypeCount, /// The confidence value for the [`Entity`]. /// /// It's currently not possible to query for the entity confidence value directly. @@ -466,13 +473,6 @@ pub enum EntityQueryPath<'p> { /// [`Entity`]: type_system::knowledge::Entity /// [`EntityType`]: type_system::ontology::entity_type::EntityType FirstTypeTitle, - /// Corresponds to the title of the [`Entity`]'s last [`EntityType`]. - /// - /// It's currently not possible to query for the last title directly. - /// - /// [`Entity`]: type_system::knowledge::Entity - /// [`EntityType`]: type_system::ontology::entity_type::EntityType - LastTypeTitle, /// Corresponds to the first set label of the [`Entity`] as specified by it's [`EntityType`]s. /// /// It's currently not possible to query for the first label directly. @@ -480,13 +480,6 @@ pub enum EntityQueryPath<'p> { /// [`Entity`]: type_system::knowledge::Entity /// [`EntityType`]: type_system::ontology::entity_type::EntityType FirstLabel, - /// Corresponds to the last set label of the [`Entity`] as specified by it's [`EntityType`]s. - /// - /// It's currently not possible to query for the last label directly. - /// - /// [`Entity`]: type_system::knowledge::Entity - /// [`EntityType`]: type_system::ontology::entity_type::EntityType - LastLabel, } impl fmt::Display for EntityQueryPath<'_> { @@ -499,7 +492,8 @@ impl fmt::Display for EntityQueryPath<'_> { Self::DecisionTime => fmt.write_str("decisionTime"), Self::TransactionTime => fmt.write_str("transactionTime"), Self::TypeBaseUrls => fmt.write_str("typeBaseUrls"), - Self::TypeVersions => fmt.write_str("typeVersions"), + 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}"), Self::Properties(None) => fmt.write_str("properties"), @@ -547,9 +541,7 @@ impl fmt::Display for EntityQueryPath<'_> { Self::RightEntityConfidence => fmt.write_str("rightEntityConfidence"), Self::RightEntityProvenance => fmt.write_str("rightEntityProvenance"), Self::FirstTypeTitle => fmt.write_str("firstTypeTitle"), - Self::LastTypeTitle => fmt.write_str("lasttTypeTitle"), Self::FirstLabel => fmt.write_str("firstLabel"), - Self::LastLabel => fmt.write_str("lastLabel"), } } } @@ -559,10 +551,10 @@ impl QueryPath for EntityQueryPath<'_> { match self { Self::EditionId | Self::Uuid | Self::WebId | Self::DraftId => ParameterType::Uuid, Self::DecisionTime | Self::TransactionTime => ParameterType::TimeInterval, - Self::TypeBaseUrls => ParameterType::Vector(Box::new(ParameterType::VersionedUrl)), - Self::TypeVersions => { - ParameterType::Vector(Box::new(ParameterType::OntologyTypeVersion)) + Self::TypeBaseUrls | Self::TypeVersionedUrls => { + ParameterType::Vector(Box::new(ParameterType::VersionedUrl)) } + Self::DirectTypeCount => ParameterType::Integer, Self::Properties(_) | Self::Label { .. } | Self::Provenance(_) @@ -577,9 +569,7 @@ impl QueryPath for EntityQueryPath<'_> { Self::Archived => ParameterType::Boolean, Self::EntityTypeEdge { path, .. } => path.expected_type(), Self::EntityEdge { path, .. } => path.expected_type(), - Self::FirstTypeTitle | Self::LastTypeTitle | Self::FirstLabel | Self::LastLabel => { - ParameterType::Text - } + Self::FirstTypeTitle | Self::FirstLabel => ParameterType::Text, } } } @@ -920,7 +910,8 @@ impl<'de: 'p, 'p> EntityQueryPath<'p> { Self::DecisionTime => EntityQueryPath::DecisionTime, Self::TransactionTime => EntityQueryPath::TransactionTime, Self::TypeBaseUrls => EntityQueryPath::TypeBaseUrls, - Self::TypeVersions => EntityQueryPath::TypeVersions, + Self::TypeVersionedUrls => EntityQueryPath::TypeVersionedUrls, + Self::DirectTypeCount => EntityQueryPath::DirectTypeCount, Self::Archived => EntityQueryPath::Archived, Self::EntityTypeEdge { path, @@ -956,9 +947,7 @@ impl<'de: 'p, 'p> EntityQueryPath<'p> { EntityQueryPath::PropertyMetadata(path.map(JsonPath::into_owned)) } Self::FirstTypeTitle => EntityQueryPath::FirstTypeTitle, - Self::LastTypeTitle => EntityQueryPath::LastTypeTitle, Self::FirstLabel => EntityQueryPath::FirstLabel, - Self::LastLabel => EntityQueryPath::LastLabel, } } } @@ -986,19 +975,7 @@ impl<'s, 'de: 's> Deserialize<'de> for EntityQuerySortingRecord<'s> { pub nulls: Option, } - let mut record = EntityQuerySortingRecord::deserialize(deserializer)?; - // If we sort in descending order, we use the last title/label instead of the first one. - // TODO: Change behavior when order is fixed - // see https://linear.app/hash/issue/H-3997/make-ontology-type-ids-ordered-in-inheritance-and-entities - match (&record.path, record.ordering) { - (EntityQueryPath::FirstTypeTitle, Ordering::Descending) => { - record.path = EntityQueryPath::LastTypeTitle; - } - (EntityQueryPath::FirstLabel, Ordering::Descending) => { - record.path = EntityQueryPath::LastLabel; - } - _ => {} - } + let record = EntityQuerySortingRecord::deserialize(deserializer)?; Ok(Self { path: record.path, diff --git a/libs/@local/graph/store/src/filter/protection.rs b/libs/@local/graph/store/src/filter/protection.rs index cbb6e24edbc..4226c5e4d94 100644 --- a/libs/@local/graph/store/src/filter/protection.rs +++ b/libs/@local/graph/store/src/filter/protection.rs @@ -845,9 +845,7 @@ fn collect_from_path<'f, 'p, I: Extend<&'f PropertyFilter<'p>>>( collect_from_json_path(json_path.as_ref(), config, excluded); } EntityQueryPath::EntityEdge { path, .. } => collect_from_path(path, config, excluded), - EntityQueryPath::Label { .. } - | EntityQueryPath::FirstLabel - | EntityQueryPath::LastLabel => { + EntityQueryPath::Label { .. } | EntityQueryPath::FirstLabel => { // TODO(BE-313): check if label_property is protected } EntityQueryPath::Embedding => { @@ -860,7 +858,8 @@ fn collect_from_path<'f, 'p, I: Extend<&'f PropertyFilter<'p>>>( | EntityQueryPath::DecisionTime | EntityQueryPath::TransactionTime | EntityQueryPath::TypeBaseUrls - | EntityQueryPath::TypeVersions + | EntityQueryPath::TypeVersionedUrls + | EntityQueryPath::DirectTypeCount | EntityQueryPath::EntityConfidence | EntityQueryPath::LeftEntityConfidence | EntityQueryPath::LeftEntityProvenance @@ -874,8 +873,7 @@ fn collect_from_path<'f, 'p, I: Extend<&'f PropertyFilter<'p>>>( } | EntityQueryPath::Provenance(_) | EntityQueryPath::EditionProvenance(_) - | EntityQueryPath::FirstTypeTitle - | EntityQueryPath::LastTypeTitle => {} + | EntityQueryPath::FirstTypeTitle => {} } } diff --git a/libs/@local/hashql/eval/src/postgres/projections.rs b/libs/@local/hashql/eval/src/postgres/projections.rs index 59dbdafd577..c422060d19b 100644 --- a/libs/@local/hashql/eval/src/postgres/projections.rs +++ b/libs/@local/hashql/eval/src/postgres/projections.rs @@ -115,7 +115,7 @@ impl Projections { ColumnReference { correlation: Some(TableReference { schema: None, - name: TableName::from(Table::EntityIsOfTypeIds), + name: TableName::from(Table::EntityEditionCache), alias: Some(alias), }), name: ComputedColumn::EntityTypeIds.into(), @@ -176,15 +176,19 @@ impl Projections { from = self.build_entity_ids(from, alias); } - // entity_type_ids: self-contained LATERAL that joins entity_is_of_type_ids - // internally, unnests the parallel arrays, and aggregates into a JSONB array. + // entity_type_ids: self-contained LATERAL that joins entity_edition_cache + // internally, unnests the parallel arrays, and aggregates into a JSONB array. The + // cache arrays cover all inheritance depths with the direct types as prefix, so the + // ordinality predicate restricts the output to the entity's direct types. // // LEFT JOIN LATERAL ( // SELECT jsonb_agg(jsonb_build_object($base_url, u."b", $version, u."v")) // AS "entity_type_ids" - // FROM "entity_is_of_type_ids" AS "eit" - // CROSS JOIN LATERAL UNNEST("eit"."base_urls", "eit"."versions") AS "u"("b", "v") - // WHERE "eit"."entity_edition_id" = "base"."entity_edition_id" + // FROM "entity_edition_cache" AS "eec" + // CROSS JOIN LATERAL UNNEST("eec"."base_urls", "eec"."versions"::text[]) + // WITH ORDINALITY AS "u"("b", "v", "ordinality") + // WHERE "eec"."entity_edition_id" = "base"."entity_edition_id" + // AND "u"."ordinality" <= "eec"."direct_types" // ) AS ON TRUE if let Some(alias) = self.entity_type_ids { from = self.build_entity_type_ids(parameters, from, alias); @@ -245,59 +249,56 @@ impl Projections { .build() } + #[expect(clippy::too_many_lines)] fn build_entity_type_ids<'item>( &self, parameters: &mut Parameters<'_, impl Allocator>, from: FromItem<'item>, alias: Alias, ) -> FromItem<'item> { - let eit_ref = TableReference { + let eec_ref = TableReference { schema: None, - name: TableName::from(Identifier::from("eit")), + name: TableName::from(Identifier::from("eec")), + alias: None, + }; + let unnest_ref = TableReference { + schema: None, + name: TableName::from(Identifier::from("u")), alias: None, }; - let inner_from = FromItem::table(Table::EntityIsOfTypeIds) - .alias(TableReference { - schema: None, - name: TableName::from(Identifier::from("eit")), - alias: None, - }) + let inner_from = FromItem::table(Table::EntityEditionCache) + .alias(eec_ref.clone()) .build() .cross_join(FromItem::Function { lateral: true, function: query::Function::Unnest(vec![ query::Expression::ColumnReference(ColumnReference { - correlation: Some(eit_ref.clone()), - name: Column::EntityIsOfTypeIds(table::EntityIsOfTypeIds::BaseUrls).into(), + correlation: Some(eec_ref.clone()), + name: Column::EntityEditionCache(table::EntityEditionCache::BaseUrls) + .into(), }), query::Expression::ColumnReference(ColumnReference { - correlation: Some(eit_ref), - name: Column::EntityIsOfTypeIds(table::EntityIsOfTypeIds::Versions).into(), + correlation: Some(eec_ref.clone()), + name: Column::EntityEditionCache(table::EntityEditionCache::Versions) + .into(), }) .cast(PostgresType::Array(Box::new(PostgresType::Text))), ]), - with_ordinality: false, - alias: Some(TableReference { - schema: None, - name: TableName::from(Identifier::from("u")), - alias: None, - }), + with_ordinality: true, + alias: Some(unnest_ref.clone()), column_alias: vec![ ColumnName::from(Identifier::from("b")), ColumnName::from(Identifier::from("v")), + ColumnName::from(Identifier::from("ordinality")), ], }); - // WHERE "eit"."entity_edition_id" = "base"."entity_edition_id" + // WHERE "eec"."entity_edition_id" = "base"."entity_edition_id" let correlation = query::Expression::equal( query::Expression::ColumnReference(ColumnReference { - correlation: Some(TableReference { - schema: None, - name: TableName::from(Identifier::from("eit")), - alias: None, - }), - name: Column::EntityIsOfType(table::EntityIsOfType::EntityEditionId, None).into(), + correlation: Some(eec_ref.clone()), + name: Column::EntityEditionCache(table::EntityEditionCache::EntityEditionId).into(), }), query::Expression::ColumnReference(ColumnReference { correlation: Some(self.temporal_metadata()), @@ -305,6 +306,18 @@ impl Projections { .into(), }), ); + // AND "u"."ordinality" <= "eec"."direct_types": the cache arrays cover all inheritance + // depths with the direct types as prefix; `entity_type_ids` only exposes direct types. + let direct_prefix = query::Expression::less_or_equal( + query::Expression::ColumnReference(ColumnReference { + correlation: Some(unnest_ref), + name: ColumnName::from(Identifier::from("ordinality")), + }), + query::Expression::ColumnReference(ColumnReference { + correlation: Some(eec_ref), + name: Column::EntityEditionCache(table::EntityEditionCache::DirectTypes).into(), + }), + ); let subquery = SelectStatement::builder() .selects(vec![SelectExpression::Expression { @@ -332,6 +345,7 @@ impl Projections { .where_expression({ let mut w = query::WhereExpression::default(); w.add_condition(correlation); + w.add_condition(direct_prefix); w }) .build(); @@ -341,7 +355,7 @@ impl Projections { statement: Box::new(subquery), alias: Some(TableReference { schema: None, - name: TableName::from(Table::EntityIsOfTypeIds), + name: TableName::from(Table::EntityEditionCache), alias: Some(alias), }), column_alias: vec![], diff --git a/libs/@local/hashql/eval/tests/ui/postgres/entity-type-ids-lateral.stdout b/libs/@local/hashql/eval/tests/ui/postgres/entity-type-ids-lateral.stdout index 886d669d8d6..4d155cc1e37 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/entity-type-ids-lateral.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/entity-type-ids-lateral.stdout @@ -3,11 +3,11 @@ SELECT ("continuation_2_0"."row")."block" AS "continuation_2_0_block", ("continuation_2_0"."row")."locals" AS "continuation_2_0_locals", ("continuation_2_0"."row")."values" AS "continuation_2_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" LEFT OUTER JOIN LATERAL (SELECT jsonb_agg(jsonb_build_object(($4::text), "b", ($5::text), "v")) AS "entity_type_ids" -FROM "entity_is_of_type_ids" AS "eit" -CROSS JOIN LATERAL UNNEST("eit"."base_urls", ("eit"."versions"::text[])) AS "u"("b", "v") -WHERE "eit"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id") AS "entity_is_of_type_ids_0_0_1" +FROM "entity_edition_cache" AS "eec" +CROSS JOIN LATERAL UNNEST("eec"."base_urls", ("eec"."versions"::text[])) WITH ORDINALITY AS "u"("b", "v", "ordinality") +WHERE "eec"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" AND "u"."ordinality" <= "eec"."direct_types") AS "entity_edition_cache_0_0_1" ON TRUE -CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_is_of_type_ids_0_0_1"."entity_type_ids") = to_jsonb(($3::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row") AS "continuation_2_0" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_edition_cache_0_0_1"."entity_type_ids") = to_jsonb(($3::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row") AS "continuation_2_0" WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_2_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/data_island_provides_without_lateral.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/data_island_provides_without_lateral.snap index 260abb650f9..5a1e06110e6 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/data_island_provides_without_lateral.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/data_island_provides_without_lateral.snap @@ -82,7 +82,7 @@ SELECT END ) ) AS "temporal_versioning", - "entity_is_of_type_ids_0_0_2"."entity_type_ids" AS "entity_type_ids", + "entity_edition_cache_0_0_2"."entity_type_ids" AS "entity_type_ids", "entity_editions_0_0_1"."archived" AS "archived", "entity_editions_0_0_1"."confidence" AS "confidence", "entity_ids_0_0_3"."provenance" AS "provenance_inferred", @@ -111,13 +111,16 @@ LEFT OUTER JOIN LATERAL ( jsonb_agg( jsonb_build_object(($12::text), "b", ($13::text), "v") ) AS "entity_type_ids" - FROM "entity_is_of_type_ids" AS "eit" + FROM "entity_edition_cache" AS "eec" CROSS JOIN LATERAL - unnest("eit"."base_urls", ("eit"."versions"::text [])) AS "u" ("b", "v") + unnest( + "eec"."base_urls", ("eec"."versions"::text []) + ) WITH ORDINALITY AS "u" ("b", "v", "ordinality") WHERE - "eit"."entity_edition_id" + "eec"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" -) AS "entity_is_of_type_ids_0_0_2" + AND "u"."ordinality" <= "eec"."direct_types" +) AS "entity_edition_cache_0_0_2" ON TRUE LEFT OUTER JOIN "entity_has_left_entity" AS "entity_has_left_entity_0_0_4" ON diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/property_mask.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/property_mask.snap index 62499ece400..2b83c98f4d8 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/property_mask.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/property_mask.snap @@ -89,7 +89,7 @@ SELECT END ) ) AS "temporal_versioning", - "entity_is_of_type_ids_0_0_2"."entity_type_ids" AS "entity_type_ids", + "entity_edition_cache_0_0_2"."entity_type_ids" AS "entity_type_ids", "entity_editions_0_0_1"."archived" AS "archived", "entity_editions_0_0_1"."confidence" AS "confidence", "entity_ids_0_0_3"."provenance" AS "provenance_inferred", @@ -121,13 +121,16 @@ LEFT OUTER JOIN LATERAL ( jsonb_agg( jsonb_build_object(($12::text), "b", ($13::text), "v") ) AS "entity_type_ids" - FROM "entity_is_of_type_ids" AS "eit" + FROM "entity_edition_cache" AS "eec" CROSS JOIN LATERAL - unnest("eit"."base_urls", ("eit"."versions"::text [])) AS "u" ("b", "v") + unnest( + "eec"."base_urls", ("eec"."versions"::text []) + ) WITH ORDINALITY AS "u" ("b", "v", "ordinality") WHERE - "eit"."entity_edition_id" + "eec"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" -) AS "entity_is_of_type_ids_0_0_2" + AND "u"."ordinality" <= "eec"."direct_types" +) AS "entity_edition_cache_0_0_2" ON TRUE LEFT OUTER JOIN "entity_has_left_entity" AS "entity_has_left_entity_0_0_4" ON diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/provides_drives_select_and_joins.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/provides_drives_select_and_joins.snap index 675055a2e51..a918721f4c9 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/provides_drives_select_and_joins.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/provides_drives_select_and_joins.snap @@ -84,7 +84,7 @@ SELECT END ) ) AS "temporal_versioning", - "entity_is_of_type_ids_0_0_2"."entity_type_ids" AS "entity_type_ids", + "entity_edition_cache_0_0_2"."entity_type_ids" AS "entity_type_ids", "entity_editions_0_0_1"."archived" AS "archived", "entity_editions_0_0_1"."confidence" AS "confidence", "entity_ids_0_0_3"."provenance" AS "provenance_inferred", @@ -113,13 +113,16 @@ LEFT OUTER JOIN LATERAL ( jsonb_agg( jsonb_build_object(($12::text), "b", ($13::text), "v") ) AS "entity_type_ids" - FROM "entity_is_of_type_ids" AS "eit" + FROM "entity_edition_cache" AS "eec" CROSS JOIN LATERAL - unnest("eit"."base_urls", ("eit"."versions"::text [])) AS "u" ("b", "v") + unnest( + "eec"."base_urls", ("eec"."versions"::text []) + ) WITH ORDINALITY AS "u" ("b", "v", "ordinality") WHERE - "eit"."entity_edition_id" + "eec"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" -) AS "entity_is_of_type_ids_0_0_2" + AND "u"."ordinality" <= "eec"."direct_types" +) AS "entity_edition_cache_0_0_2" ON TRUE LEFT OUTER JOIN "entity_has_left_entity" AS "entity_has_left_entity_0_0_4" ON