diff --git a/.github/java-tests/impact-map.json b/.github/java-tests/impact-map.json index 19975afcc8cf..b47137fcfa27 100644 --- a/.github/java-tests/impact-map.json +++ b/.github/java-tests/impact-map.json @@ -703,6 +703,7 @@ "openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/**", "openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/Ontology*", "openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/RelationshipType*", + "openmetadata-spec/src/main/resources/json/schema/entity/data/ontology*.json", "openmetadata-service/src/main/resources/json/data/ontology/**", "openmetadata-spec/src/main/resources/json/schema/api/data/*Ontology*", "openmetadata-spec/src/main/resources/json/schema/api/data/ontology*" diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index a5c2053bdd35..a845f7d0ab91 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -2429,6 +2429,7 @@ "playwright/e2e/Features/CuratedAssets.spec.ts", "playwright/e2e/Features/CustomizeDetailPage.spec.ts", "playwright/e2e/Features/NavigationBlocker.spec.ts", + "playwright/e2e/Features/OntologyReview.spec.ts", "playwright/e2e/Features/PersonaOverviewCardWidgets.spec.ts", "playwright/e2e/Flow/CustomizeLandingPage.spec.ts", "playwright/e2e/Flow/ObservabilityAlerts.spec.ts", @@ -3328,6 +3329,7 @@ "playwright/e2e/Features/AdvancedSearch.spec.ts", "playwright/e2e/Features/ContextCenterDashboard.spec.ts", "playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts", + "playwright/e2e/Features/DataQuality/ProfilerIngestionForm.spec.ts", "playwright/e2e/Features/DataQuality/TableTestCaseExportFilters.spec.ts", "playwright/e2e/Features/DomainFilterQueryFilter.spec.ts", "playwright/e2e/Features/LandingPageWidgets/LandingPageContextCenter.spec.ts", @@ -3337,6 +3339,7 @@ "playwright/e2e/Features/LandingPageWidgets/LandingPagePipelineStatus.spec.ts", "playwright/e2e/Features/LandingPageWidgets/LandingPagePlatformHealth.spec.ts", "playwright/e2e/Features/LandingPageWidgets/LandingPageTotalDataAssets.spec.ts", + "playwright/e2e/Features/OntologyReview.spec.ts", "playwright/e2e/Features/PersonaAppLayout.spec.ts", "playwright/e2e/Features/Table.spec.ts", "playwright/e2e/Features/Workflows/ChangeRequestReviewLifecycle.spec.ts", @@ -3412,6 +3415,7 @@ "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/Profiler.spec.ts", "playwright/e2e/Features/DataQuality/TableLevelTests.spec.ts", + "playwright/e2e/Features/OntologyReview.spec.ts", "playwright/e2e/Features/Permission.spec.ts", "playwright/e2e/Features/QueryEntity.spec.ts", "playwright/e2e/Features/SchemaDefinition.spec.ts", @@ -3614,6 +3618,7 @@ "playwright/e2e/Features/NavigationBlocker.spec.ts", "playwright/e2e/Features/NestedColumnsExpandCollapse.spec.ts", "playwright/e2e/Features/OnlineUsers.spec.ts", + "playwright/e2e/Features/OntologyReview.spec.ts", "playwright/e2e/Features/OntologyStudioIntegration.spec.ts", "playwright/e2e/Features/Pagination.spec.ts", "playwright/e2e/Features/Permission.spec.ts", @@ -4667,6 +4672,7 @@ "playwright/e2e/Features/Glossary/GlossaryRelationsGraph.spec.ts", "playwright/e2e/Features/Glossary/GlossaryTermInverseRelation.spec.ts", "playwright/e2e/Features/Glossary/GlossaryTermRelationsGraph.spec.ts", + "playwright/e2e/Features/OntologyReview.spec.ts", "playwright/e2e/Features/OntologyStudio.spec.ts", "playwright/e2e/Features/OntologyStudioCardinality.spec.ts", "playwright/e2e/Features/OntologyStudioE2E.spec.ts", @@ -6893,6 +6899,7 @@ "specs": [ "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/TableLevelTests.spec.ts", + "playwright/e2e/Features/OntologyReview.spec.ts", "playwright/e2e/Features/QueryEntity.spec.ts", "playwright/e2e/Pages/DataContracts.spec.ts" ] @@ -6904,6 +6911,7 @@ "specs": [ "playwright/e2e/Features/DataQuality/FailureThreshold.spec.ts", "playwright/e2e/Features/DataQuality/TableLevelTests.spec.ts", + "playwright/e2e/Features/OntologyReview.spec.ts", "playwright/e2e/Features/QueryEntity.spec.ts", "playwright/e2e/Pages/DataContracts.spec.ts" ] @@ -8245,6 +8253,7 @@ "playwright/e2e/Features/Glossary/GlossaryRelationsGraph.spec.ts", "playwright/e2e/Features/Glossary/GlossaryRelationsGraphPerf.spec.ts", "playwright/e2e/Features/Glossary/GlossaryTermRelationsGraph.spec.ts", + "playwright/e2e/Features/OntologyReview.spec.ts", "playwright/e2e/Features/OntologyStudio.spec.ts", "playwright/e2e/Features/OntologyStudioCardinality.spec.ts", "playwright/e2e/Features/OntologyStudioE2E.spec.ts", @@ -8267,6 +8276,14 @@ "playwright/e2e/Features/OntologyStudio.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyMemoryReviewPanel.tsx" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyModelingWorkbench.tsx" @@ -8275,6 +8292,22 @@ "playwright/e2e/Features/OntologyStudio.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyQueryResults.tsx" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyStudioQueryConsole.tsx" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyTreeView.tsx" @@ -8668,6 +8701,7 @@ "openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/AddIngestion.component.tsx" ], "specs": [ + "playwright/e2e/Features/DataQuality/ProfilerIngestionForm.spec.ts", "playwright/e2e/Features/StorageMetadataAgentForm.spec.ts" ] }, @@ -9430,6 +9464,7 @@ "playwright/e2e/Features/Glossary/GlossaryMiscOperations.spec.ts", "playwright/e2e/Features/Glossary/GlossaryWorkflow.spec.ts", "playwright/e2e/Features/MetricCustomUnitFlow.spec.ts", + "playwright/e2e/Features/OntologyReview.spec.ts", "playwright/e2e/Features/PersonaAIContext.spec.ts", "playwright/e2e/Features/PersonaAIContextRules.spec.ts", "playwright/e2e/Flow/ApiServiceRest.spec.ts", @@ -9463,6 +9498,7 @@ "playwright/e2e/Features/Glossary/GlossaryMiscOperations.spec.ts", "playwright/e2e/Features/Glossary/GlossaryWorkflow.spec.ts", "playwright/e2e/Features/MetricCustomUnitFlow.spec.ts", + "playwright/e2e/Features/OntologyReview.spec.ts", "playwright/e2e/Features/PersonaAIContext.spec.ts", "playwright/e2e/Features/PersonaAIContextRules.spec.ts", "playwright/e2e/Flow/ApiServiceRest.spec.ts", @@ -10412,7 +10448,8 @@ ], "specs": [ "playwright/e2e/Features/AIMode/InboxTaskMatrix.spec.ts", - "playwright/e2e/Features/AIMode/InboxTasks.spec.ts" + "playwright/e2e/Features/AIMode/InboxTasks.spec.ts", + "playwright/e2e/Features/OntologyReview.spec.ts" ] }, { @@ -11287,6 +11324,54 @@ "playwright/e2e/Features/AIMode/PersonaSettings.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomEditor/OntologyAxiomEditor.tsx" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.tsx" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.tsx" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.tsx" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleaseDetails.tsx" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewPanel.tsx" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/integration/ConnectionServiceDetailsPage/ConnectionServiceDetailsPage.tsx" @@ -11683,6 +11768,14 @@ "playwright/e2e/Pages/DataInsight.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/generated/entity/context/contextMemory.ts" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/generated/entity/data/apiEndpoint.ts" @@ -11905,6 +11998,54 @@ "playwright/e2e/VersionPages/ServiceEntityVersionPage.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontology.ts" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyAxiom.ts" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyChangeSet.ts" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyConcept.ts" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyProperty.ts" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyRelease.ts" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/generated/entity/data/query.ts" @@ -12708,6 +12849,14 @@ "playwright/e2e/Pages/TestSuiteDetailsPage.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/generated/entity/tasks/task.ts" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/generated/events/testDestinationStatus.ts" @@ -12742,6 +12891,14 @@ "playwright/e2e/Flow/ObservabilityAlerts.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyState.ts" + ], + "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/generated/type/personaContextDefinition.ts" @@ -13183,6 +13340,7 @@ "openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.tsx" ], "specs": [ + "playwright/e2e/Features/OntologyReview.spec.ts", "playwright/e2e/Features/OntologyStudio.spec.ts", "playwright/e2e/Features/OntologyStudioCardinality.spec.ts", "playwright/e2e/Features/OntologyStudioE2E.spec.ts", diff --git a/bootstrap/sql/migrations/native/2.1.0/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/2.1.0/mysql/schemaChanges.sql index c0ee9f1d8f2c..4260f199e05b 100644 --- a/bootstrap/sql/migrations/native/2.1.0/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.1.0/mysql/schemaChanges.sql @@ -875,6 +875,212 @@ PREPARE rdf_projection_health_watermark_stmt FROM @rdf_projection_health_waterma EXECUTE rdf_projection_health_watermark_stmt; DEALLOCATE PREPARE rdf_projection_health_watermark_stmt; +-- Independent ontology definitions and releases. +CREATE TABLE IF NOT EXISTS ontology_entity ( + id VARCHAR(36) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.id'))) STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.name'))) STORED NOT NULL, + fqnHash VARCHAR(768) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + json JSON NOT NULL, + iriHash CHAR(32) CHARACTER SET ascii COLLATE ascii_bin GENERATED ALWAYS AS (md5(json_unquote(json_extract(json, '$.iri')))) STORED NOT NULL, + updatedAt BIGINT UNSIGNED GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.updatedAt'))) STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.updatedBy'))) STORED NOT NULL, + deleted TINYINT(1) GENERATED ALWAYS AS (json_extract(json, '$.deleted')) STORED, + PRIMARY KEY (id), + CONSTRAINT ontology_fqn_hash_unique UNIQUE (fqnHash), + KEY ontology_name_index (name), + KEY ontology_deleted_index (deleted), + KEY ontology_iri_index (iriHash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS ontology_concept_entity ( + id VARCHAR(36) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.id'))) STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.name'))) STORED NOT NULL, + fqnHash VARCHAR(768) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + json JSON NOT NULL, + iriHash CHAR(32) CHARACTER SET ascii COLLATE ascii_bin GENERATED ALWAYS AS (md5(json_unquote(json_extract(json, '$.iri')))) STORED NOT NULL, + ontologyId VARCHAR(36) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.ontology.id'))) STORED NOT NULL, + conceptType VARCHAR(32) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.conceptType'))) STORED NOT NULL, + updatedAt BIGINT UNSIGNED GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.updatedAt'))) STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.updatedBy'))) STORED NOT NULL, + deleted TINYINT(1) GENERATED ALWAYS AS (json_extract(json, '$.deleted')) STORED, + PRIMARY KEY (id), + CONSTRAINT ontology_concept_fqn_hash_unique UNIQUE (fqnHash), + CONSTRAINT ontology_concept_iri_unique UNIQUE (ontologyId, iriHash), + KEY ontology_concept_name_index (name), + KEY ontology_concept_deleted_index (deleted), + KEY ontology_concept_ontology_kind_index (ontologyId, conceptType) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS ontology_property_entity ( + id VARCHAR(36) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.id'))) STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.name'))) STORED NOT NULL, + fqnHash VARCHAR(768) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + json JSON NOT NULL, + iriHash CHAR(32) CHARACTER SET ascii COLLATE ascii_bin GENERATED ALWAYS AS (md5(json_unquote(json_extract(json, '$.iri')))) STORED NOT NULL, + ontologyId VARCHAR(36) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.ontology.id'))) STORED NOT NULL, + kind VARCHAR(32) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.kind'))) STORED NOT NULL, + updatedAt BIGINT UNSIGNED GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.updatedAt'))) STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.updatedBy'))) STORED NOT NULL, + deleted TINYINT(1) GENERATED ALWAYS AS (json_extract(json, '$.deleted')) STORED, + PRIMARY KEY (id), + CONSTRAINT ontology_property_fqn_hash_unique UNIQUE (fqnHash), + CONSTRAINT ontology_property_iri_unique UNIQUE (ontologyId, iriHash), + KEY ontology_property_name_index (name), + KEY ontology_property_deleted_index (deleted), + KEY ontology_property_ontology_kind_index (ontologyId, kind) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS ontology_state ( + ontologyId VARCHAR(36) NOT NULL, + workingRevision BIGINT UNSIGNED NOT NULL DEFAULT 0, + lastReleaseRevision BIGINT UNSIGNED NOT NULL DEFAULT 0, + activeReleaseId VARCHAR(36), + activationRevision BIGINT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (ontologyId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS ontology_release_entity ( + id VARCHAR(36) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.id'))) STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.name'))) STORED NOT NULL, + fqnHash VARCHAR(768) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + json JSON NOT NULL, + ontologyId VARCHAR(36) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.ontology.id'))) STORED NOT NULL, + releaseRevision BIGINT UNSIGNED GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.releaseRevision'))) STORED NOT NULL, + entityStatus VARCHAR(32) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.entityStatus'))) STORED NOT NULL, + sealedAt BIGINT UNSIGNED GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.sealedAt'))) STORED, + updatedAt BIGINT UNSIGNED GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.updatedAt'))) STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.updatedBy'))) STORED NOT NULL, + deleted TINYINT(1) GENERATED ALWAYS AS (json_extract(json, '$.deleted')) STORED, + PRIMARY KEY (id), + CONSTRAINT ontology_release_fqn_unique UNIQUE (fqnHash), + CONSTRAINT ontology_release_revision_unique UNIQUE (ontologyId, releaseRevision), + KEY ontology_release_name_index (name), + KEY ontology_release_status_index (ontologyId, entityStatus) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE IF NOT EXISTS ontology_release_member ( + releaseId VARCHAR(36) NOT NULL, + memberKind VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + memberId VARCHAR(36) NOT NULL, + sourceVersion DOUBLE, + payload LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + payloadDigest CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + payloadBytes BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (releaseId, memberKind, memberId) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + + +CREATE TABLE IF NOT EXISTS ontology_release_event ( + id VARCHAR(36) NOT NULL, + ontologyId VARCHAR(36) NOT NULL, + releaseId VARCHAR(36) NOT NULL, + eventType VARCHAR(32) NOT NULL, + createdAt BIGINT UNSIGNED NOT NULL, + createdBy VARCHAR(256) NOT NULL, + json JSON NOT NULL, + PRIMARY KEY (id), + KEY ontology_release_event_release_index (releaseId, createdAt, id), + KEY ontology_release_event_ontology_index (ontologyId, createdAt, id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- Studio axioms are unreleased; discard glossary-owned definitions without changing glossaries. +DELETE FROM entity_relationship WHERE + (fromEntity = 'ontologyAxiom' AND fromId IN (SELECT id FROM ontology_axiom_entity WHERE json_extract(json, '$.ontology.id') IS NULL)) OR + (toEntity = 'ontologyAxiom' AND toId IN (SELECT id FROM ontology_axiom_entity WHERE json_extract(json, '$.ontology.id') IS NULL)); +DELETE FROM entity_extension WHERE id IN (SELECT id FROM ontology_axiom_entity WHERE json_extract(json, '$.ontology.id') IS NULL); +DELETE FROM ontology_axiom_entity WHERE json_extract(json, '$.ontology.id') IS NULL; + +SET @ontology_axiom_owner_ddl = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE ontology_axiom_entity DROP INDEX ontology_axiom_glossary_type_index, DROP COLUMN glossaryId, ADD COLUMN ontologyId VARCHAR(36) GENERATED ALWAYS AS (json_unquote(json_extract(json, ''$.ontology.id''))) STORED NOT NULL, ADD INDEX ontology_axiom_ontology_type_index (ontologyId, axiomType)', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ontology_axiom_entity' + AND column_name = 'glossaryId' +); +PREPARE ontology_axiom_owner_stmt FROM @ontology_axiom_owner_ddl; +EXECUTE ontology_axiom_owner_stmt; +DEALLOCATE PREPARE ontology_axiom_owner_stmt; + +CREATE TABLE IF NOT EXISTS ontology_reference ( + ontologyId VARCHAR(36) NOT NULL, + sourceType VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + sourceId VARCHAR(36) NOT NULL, + role VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + iri TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + iriHash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + PRIMARY KEY (sourceType, sourceId, role, iriHash), + KEY ontology_reference_target_index (ontologyId, iriHash, sourceType, sourceId, role) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +SET @ontology_concept_listing_index_ddl = ( + SELECT IF(COUNT(*) = 0, 'CREATE INDEX ontology_concept_listing_index ON ontology_concept_entity (ontologyId, deleted, name, id)', 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'ontology_concept_entity' AND index_name = 'ontology_concept_listing_index' +); +PREPARE ontology_concept_listing_index_stmt FROM @ontology_concept_listing_index_ddl; +EXECUTE ontology_concept_listing_index_stmt; +DEALLOCATE PREPARE ontology_concept_listing_index_stmt; + +SET @ontology_property_listing_index_ddl = ( + SELECT IF(COUNT(*) = 0, 'CREATE INDEX ontology_property_listing_index ON ontology_property_entity (ontologyId, deleted, name, id)', 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'ontology_property_entity' AND index_name = 'ontology_property_listing_index' +); +PREPARE ontology_property_listing_index_stmt FROM @ontology_property_listing_index_ddl; +EXECUTE ontology_property_listing_index_stmt; +DEALLOCATE PREPARE ontology_property_listing_index_stmt; + +SET @ontology_axiom_listing_index_ddl = ( + SELECT IF(COUNT(*) = 0, 'CREATE INDEX ontology_axiom_listing_index ON ontology_axiom_entity (ontologyId, deleted, name, id)', 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'ontology_axiom_entity' AND index_name = 'ontology_axiom_listing_index' +); +PREPARE ontology_axiom_listing_index_stmt FROM @ontology_axiom_listing_index_ddl; +EXECUTE ontology_axiom_listing_index_stmt; +DEALLOCATE PREPARE ontology_axiom_listing_index_stmt; + +-- Approval state belongs only to immutable ontology releases. +SET @ontology_release_only_review_ddl = ( + SELECT IF(COUNT(*) > 0, 'ALTER TABLE ontology_entity DROP COLUMN entityStatus', 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ontology_entity' AND column_name = 'entityStatus' +); +PREPARE ontology_release_only_review_stmt FROM @ontology_release_only_review_ddl; +EXECUTE ontology_release_only_review_stmt; +DEALLOCATE PREPARE ontology_release_only_review_stmt; +SET @ontology_concept_release_only_review_ddl = ( + SELECT IF(COUNT(*) > 0, 'ALTER TABLE ontology_concept_entity DROP COLUMN entityStatus', 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ontology_concept_entity' AND column_name = 'entityStatus' +); +PREPARE ontology_concept_release_only_review_stmt FROM @ontology_concept_release_only_review_ddl; +EXECUTE ontology_concept_release_only_review_stmt; +DEALLOCATE PREPARE ontology_concept_release_only_review_stmt; +SET @ontology_property_release_only_review_ddl = ( + SELECT IF(COUNT(*) > 0, 'ALTER TABLE ontology_property_entity DROP COLUMN entityStatus', 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ontology_property_entity' AND column_name = 'entityStatus' +); +PREPARE ontology_property_release_only_review_stmt FROM @ontology_property_release_only_review_ddl; +EXECUTE ontology_property_release_only_review_stmt; +DEALLOCATE PREPARE ontology_property_release_only_review_stmt; +SET @ontology_axiom_release_only_review_ddl = ( + SELECT IF(COUNT(*) > 0, 'ALTER TABLE ontology_axiom_entity DROP COLUMN entityStatus', 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'ontology_axiom_entity' AND column_name = 'entityStatus' +); +PREPARE ontology_axiom_release_only_review_stmt FROM @ontology_axiom_release_only_review_ddl; +EXECUTE ontology_axiom_release_only_review_stmt; +DEALLOCATE PREPARE ontology_axiom_release_only_review_stmt; + +-- Unreleased glossary axiom drafts cannot be applied to the independent ontology model. +DELETE FROM entity_relationship WHERE + (fromEntity = 'ontologyChangeSet' AND fromId IN (SELECT id FROM ontology_change_set_entity WHERE JSON_EXTRACT(json, '$.ontology.id') IS NULL AND (JSON_CONTAINS(json, '{"operationType":"UPSERT_AXIOM"}', '$.operations') OR JSON_CONTAINS(json, '{"operationType":"DELETE_AXIOM"}', '$.operations')))) OR + (toEntity = 'ontologyChangeSet' AND toId IN (SELECT id FROM ontology_change_set_entity WHERE JSON_EXTRACT(json, '$.ontology.id') IS NULL AND (JSON_CONTAINS(json, '{"operationType":"UPSERT_AXIOM"}', '$.operations') OR JSON_CONTAINS(json, '{"operationType":"DELETE_AXIOM"}', '$.operations')))); +DELETE FROM entity_extension WHERE id IN (SELECT id FROM ontology_change_set_entity WHERE JSON_EXTRACT(json, '$.ontology.id') IS NULL AND (JSON_CONTAINS(json, '{"operationType":"UPSERT_AXIOM"}', '$.operations') OR JSON_CONTAINS(json, '{"operationType":"DELETE_AXIOM"}', '$.operations'))); +DELETE FROM ontology_change_set_entity WHERE JSON_EXTRACT(json, '$.ontology.id') IS NULL AND (JSON_CONTAINS(json, '{"operationType":"UPSERT_AXIOM"}', '$.operations') OR JSON_CONTAINS(json, '{"operationType":"DELETE_AXIOM"}', '$.operations')); + -- Flowable schema upgrades run after this migration and inherit the database default. Existing -- ACT_* tables are aligned to the same collation by FlowableCharsetMigration. ALTER DATABASE CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; diff --git a/bootstrap/sql/migrations/native/2.1.0/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/2.1.0/postgres/schemaChanges.sql index 13d2c45b5642..1d45d461c133 100644 --- a/bootstrap/sql/migrations/native/2.1.0/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.1.0/postgres/schemaChanges.sql @@ -693,3 +693,156 @@ CREATE INDEX IF NOT EXISTS idx_entity_relationship_memory_anchor -- non-C collation (seq scan); text_pattern_ops as for the 1.13.0 *_fqnhash_pattern indexes. CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_about_fqn_hash_pattern ON task_entity (aboutfqnhash text_pattern_ops); + +-- Independent ontology definitions and releases. +CREATE TABLE IF NOT EXISTS ontology_entity ( + id VARCHAR(36) GENERATED ALWAYS AS (json ->> 'id') STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json ->> 'name') STORED NOT NULL, + fqnHash VARCHAR(768) NOT NULL, + json JSONB NOT NULL, + iriHash CHAR(32) GENERATED ALWAYS AS (md5(json ->> 'iri')) STORED NOT NULL, + updatedAt BIGINT GENERATED ALWAYS AS ((json ->> 'updatedAt')::bigint) STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> 'updatedBy') STORED NOT NULL, + deleted BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED, + PRIMARY KEY (id), + CONSTRAINT ontology_fqn_hash_unique UNIQUE (fqnHash) +); +CREATE INDEX IF NOT EXISTS ontology_name_index ON ontology_entity (name); +CREATE INDEX IF NOT EXISTS ontology_deleted_index ON ontology_entity (deleted); +CREATE INDEX IF NOT EXISTS ontology_iri_index ON ontology_entity (iriHash); + +CREATE TABLE IF NOT EXISTS ontology_concept_entity ( + id VARCHAR(36) GENERATED ALWAYS AS (json ->> 'id') STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json ->> 'name') STORED NOT NULL, + fqnHash VARCHAR(768) NOT NULL, + json JSONB NOT NULL, + iriHash CHAR(32) GENERATED ALWAYS AS (md5(json ->> 'iri')) STORED NOT NULL, + ontologyId VARCHAR(36) GENERATED ALWAYS AS (json -> 'ontology' ->> 'id') STORED NOT NULL, + conceptType VARCHAR(32) GENERATED ALWAYS AS (json ->> 'conceptType') STORED NOT NULL, + updatedAt BIGINT GENERATED ALWAYS AS ((json ->> 'updatedAt')::bigint) STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> 'updatedBy') STORED NOT NULL, + deleted BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED, + PRIMARY KEY (id), + CONSTRAINT ontology_concept_fqn_hash_unique UNIQUE (fqnHash), + CONSTRAINT ontology_concept_iri_unique UNIQUE (ontologyId, iriHash) +); +CREATE INDEX IF NOT EXISTS ontology_concept_name_index ON ontology_concept_entity (name); +CREATE INDEX IF NOT EXISTS ontology_concept_deleted_index ON ontology_concept_entity (deleted); +CREATE INDEX IF NOT EXISTS ontology_concept_ontology_kind_index ON ontology_concept_entity (ontologyId, conceptType); + +CREATE TABLE IF NOT EXISTS ontology_property_entity ( + id VARCHAR(36) GENERATED ALWAYS AS (json ->> 'id') STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json ->> 'name') STORED NOT NULL, + fqnHash VARCHAR(768) NOT NULL, + json JSONB NOT NULL, + iriHash CHAR(32) GENERATED ALWAYS AS (md5(json ->> 'iri')) STORED NOT NULL, + ontologyId VARCHAR(36) GENERATED ALWAYS AS (json -> 'ontology' ->> 'id') STORED NOT NULL, + kind VARCHAR(32) GENERATED ALWAYS AS (json ->> 'kind') STORED NOT NULL, + updatedAt BIGINT GENERATED ALWAYS AS ((json ->> 'updatedAt')::bigint) STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> 'updatedBy') STORED NOT NULL, + deleted BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED, + PRIMARY KEY (id), + CONSTRAINT ontology_property_fqn_hash_unique UNIQUE (fqnHash), + CONSTRAINT ontology_property_iri_unique UNIQUE (ontologyId, iriHash) +); +CREATE INDEX IF NOT EXISTS ontology_property_name_index ON ontology_property_entity (name); +CREATE INDEX IF NOT EXISTS ontology_property_deleted_index ON ontology_property_entity (deleted); +CREATE INDEX IF NOT EXISTS ontology_property_ontology_kind_index ON ontology_property_entity (ontologyId, kind); + +CREATE TABLE IF NOT EXISTS ontology_state ( + ontologyId VARCHAR(36) NOT NULL, + workingRevision BIGINT NOT NULL DEFAULT 0 CHECK (workingRevision >= 0), + lastReleaseRevision BIGINT NOT NULL DEFAULT 0 CHECK (lastReleaseRevision >= 0), + activeReleaseId VARCHAR(36), + activationRevision BIGINT NOT NULL DEFAULT 0 CHECK (activationRevision >= 0), + PRIMARY KEY (ontologyId) +); + +CREATE TABLE IF NOT EXISTS ontology_release_entity ( + id VARCHAR(36) GENERATED ALWAYS AS (json ->> 'id') STORED NOT NULL, + name VARCHAR(256) GENERATED ALWAYS AS (json ->> 'name') STORED NOT NULL, + fqnHash VARCHAR(768) NOT NULL, + json JSONB NOT NULL, + ontologyId VARCHAR(36) GENERATED ALWAYS AS (json -> 'ontology' ->> 'id') STORED NOT NULL, + releaseRevision BIGINT GENERATED ALWAYS AS ((json ->> 'releaseRevision')::bigint) STORED NOT NULL, + entityStatus VARCHAR(32) GENERATED ALWAYS AS (json ->> 'entityStatus') STORED NOT NULL, + sealedAt BIGINT GENERATED ALWAYS AS ((json ->> 'sealedAt')::bigint) STORED, + updatedAt BIGINT GENERATED ALWAYS AS ((json ->> 'updatedAt')::bigint) STORED NOT NULL, + updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> 'updatedBy') STORED NOT NULL, + deleted BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED, + PRIMARY KEY (id), + CONSTRAINT ontology_release_fqn_unique UNIQUE (fqnHash), + CONSTRAINT ontology_release_revision_unique UNIQUE (ontologyId, releaseRevision) +); +CREATE INDEX IF NOT EXISTS ontology_release_name_index ON ontology_release_entity (name); +CREATE INDEX IF NOT EXISTS ontology_release_status_index ON ontology_release_entity (ontologyId, entityStatus); + +CREATE TABLE IF NOT EXISTS ontology_release_member ( + releaseId VARCHAR(36) NOT NULL, + memberKind VARCHAR(32) NOT NULL, + memberId VARCHAR(36) NOT NULL, + sourceVersion DOUBLE PRECISION, + payload TEXT NOT NULL, + payloadDigest CHAR(64) NOT NULL, + payloadBytes BIGINT NOT NULL CHECK (payloadBytes > 0), + PRIMARY KEY (releaseId, memberKind, memberId) +); + + +CREATE TABLE IF NOT EXISTS ontology_release_event ( + id VARCHAR(36) NOT NULL, + ontologyId VARCHAR(36) NOT NULL, + releaseId VARCHAR(36) NOT NULL, + eventType VARCHAR(32) NOT NULL, + createdAt BIGINT NOT NULL, + createdBy VARCHAR(256) NOT NULL, + json JSONB NOT NULL, + PRIMARY KEY (id) +); +CREATE INDEX IF NOT EXISTS ontology_release_event_release_index ON ontology_release_event (releaseId, createdAt, id); +CREATE INDEX IF NOT EXISTS ontology_release_event_ontology_index ON ontology_release_event (ontologyId, createdAt, id); + +-- Studio axioms are unreleased; discard glossary-owned definitions without changing glossaries. +DELETE FROM entity_relationship WHERE + (fromEntity = 'ontologyAxiom' AND fromId IN (SELECT id FROM ontology_axiom_entity WHERE json -> 'ontology' ->> 'id' IS NULL)) OR + (toEntity = 'ontologyAxiom' AND toId IN (SELECT id FROM ontology_axiom_entity WHERE json -> 'ontology' ->> 'id' IS NULL)); +DELETE FROM entity_extension WHERE id IN (SELECT id FROM ontology_axiom_entity WHERE json -> 'ontology' ->> 'id' IS NULL); +DELETE FROM ontology_axiom_entity WHERE json -> 'ontology' ->> 'id' IS NULL; + +DROP INDEX IF EXISTS ontology_axiom_glossary_type_index; +ALTER TABLE ontology_axiom_entity DROP COLUMN IF EXISTS glossaryId; +ALTER TABLE ontology_axiom_entity ADD COLUMN IF NOT EXISTS ontologyId VARCHAR(36) + GENERATED ALWAYS AS (json -> 'ontology' ->> 'id') STORED NOT NULL; +CREATE INDEX IF NOT EXISTS ontology_axiom_ontology_type_index ON ontology_axiom_entity (ontologyId, axiomType); + +CREATE TABLE IF NOT EXISTS ontology_reference ( + ontologyId VARCHAR(36) NOT NULL, + sourceType VARCHAR(64) COLLATE "C" NOT NULL, + sourceId VARCHAR(36) NOT NULL, + role VARCHAR(32) COLLATE "C" NOT NULL, + iri TEXT COLLATE "C" NOT NULL, + iriHash CHAR(64) COLLATE "C" NOT NULL, + PRIMARY KEY (sourceType, sourceId, role, iriHash) +); + +CREATE INDEX IF NOT EXISTS ontology_reference_target_index + ON ontology_reference (ontologyId, iriHash, sourceType, sourceId, role); + +CREATE INDEX IF NOT EXISTS ontology_concept_listing_index ON ontology_concept_entity (ontologyId, deleted, name, id); + +CREATE INDEX IF NOT EXISTS ontology_property_listing_index ON ontology_property_entity (ontologyId, deleted, name, id); + +CREATE INDEX IF NOT EXISTS ontology_axiom_listing_index ON ontology_axiom_entity (ontologyId, deleted, name, id); + +-- Approval state belongs only to immutable ontology releases. +ALTER TABLE ontology_entity DROP COLUMN IF EXISTS entityStatus; +ALTER TABLE ontology_concept_entity DROP COLUMN IF EXISTS entityStatus; +ALTER TABLE ontology_property_entity DROP COLUMN IF EXISTS entityStatus; +ALTER TABLE ontology_axiom_entity DROP COLUMN IF EXISTS entityStatus; + +-- Unreleased glossary axiom drafts cannot be applied to the independent ontology model. +DELETE FROM entity_relationship WHERE + (fromEntity = 'ontologyChangeSet' AND fromId IN (SELECT id FROM ontology_change_set_entity WHERE json->'ontology'->>'id' IS NULL AND (json->'operations' @> '[{"operationType":"UPSERT_AXIOM"}]'::jsonb OR json->'operations' @> '[{"operationType":"DELETE_AXIOM"}]'::jsonb))) OR + (toEntity = 'ontologyChangeSet' AND toId IN (SELECT id FROM ontology_change_set_entity WHERE json->'ontology'->>'id' IS NULL AND (json->'operations' @> '[{"operationType":"UPSERT_AXIOM"}]'::jsonb OR json->'operations' @> '[{"operationType":"DELETE_AXIOM"}]'::jsonb))); +DELETE FROM entity_extension WHERE id IN (SELECT id FROM ontology_change_set_entity WHERE json->'ontology'->>'id' IS NULL AND (json->'operations' @> '[{"operationType":"UPSERT_AXIOM"}]'::jsonb OR json->'operations' @> '[{"operationType":"DELETE_AXIOM"}]'::jsonb)); +DELETE FROM ontology_change_set_entity WHERE json->'ontology'->>'id' IS NULL AND (json->'operations' @> '[{"operationType":"UPSERT_AXIOM"}]'::jsonb OR json->'operations' @> '[{"operationType":"DELETE_AXIOM"}]'::jsonb); diff --git a/docs/decisions/2026-10-14-ontology-definitions-have-independent-entity-storage.md b/docs/decisions/2026-10-14-ontology-definitions-have-independent-entity-storage.md new file mode 100644 index 000000000000..59cc73f98382 --- /dev/null +++ b/docs/decisions/2026-10-14-ontology-definitions-have-independent-entity-storage.md @@ -0,0 +1,138 @@ +# Ontology definitions use independent entity storage + +- **Status:** Accepted +- **Revisions:** v1 2026-10-14 (initial) · v2 2026-10-14 (semantic identity) · v3 2026-10-14 (native IRI creation) · v4 2026-10-14 (client IRI fidelity) · v5 2026-10-15 (native axiom ownership) · v6 2026-10-15 (derived reference index) · v7 2026-10-15 (unreleased 2.1.0 migrations and scoped axiom reset) · v8 2026-10-15 (metadata usage search by concept UUID) · v9 2026-10-15 (shared search and CSV definitions) · v10 2026-10-15 (release-only review and affected-definition validation) · v11 2026-10-15 (metadata annotations moved to a follow-up) +- **Deciders:** Harsha +- **Guard:** `OntologyEntityDAOTest`, `OntologyDefinitionTest`, `CollectionDAOCompositionTest`, `IndependentOntologyAxiomIT`, `OntologyReferencesTest`, `OntologyReferenceDAOTest`, `OntologyReleaseCaptureTest`, `IndependentOntologyReferenceIT` +- **Related:** [Organizational ontology design](../plans/2026-10-14-organizational-ontology-design.md) + +## Context + +Ontology Studio currently stores its formal model in glossaries and glossary terms. Organizations +need independently governed ontology definitions, while business glossaries must retain their +configurable relationships, tagging and Relations Graph. Ontology Studio is unreleased, so existing +glossaries need no conversion into ontology entities. + +## Decision + +Store `Ontology`, `OntologyConcept` and `OntologyProperty` in dedicated entity tables. Use the +existing `EntityDAO` JSON persistence, FQN hashing, generated database columns and native migration +mechanisms on both MySQL and PostgreSQL. Schema-first models carry explicit semantic IRIs and +ontology membership. Concepts and properties have separate identity and kind fields; neither is a +glossary term or a glossary relationship type. + +Keep the existing `OntologyDAO` glossary graph queries and `RelationshipType` registry. Name the +new ontology entity accessor `ontologyEntityDAO` so adding independent storage does not retarget +glossary graph callers. Append all ontology DDL to the unreleased native `2.1.0` migration on +both databases. The runner reprocesses the highest executed version in each release train; adding +a later 2.1.x migration would prevent subsequent 2.1.0 additions from running on upgraded databases. +Use unique MySQL guard variables and statement names because the runner deduplicates statement +text across versions. Do not convert glossary records. + +## Consequences + +The storage foundation can be tested without a glossary or RDF store. Repository lifecycle, +approval enforcement, ontology API and Studio integration, release storage and metadata annotations +remain separate implementation steps from this storage change. Working definitions use ordinary +entity history. Only immutable releases carry an approval status; working edits never grant +publication approval. Metadata annotations, search projection and CSV support below are preserved +in a follow-up patch; the native entity/publishing change does not extend `TagLabel`. + +The Studio UI and glossary Relations Graph remain supported. Reusing graph rendering does not +justify sharing their domain-specific persistence or mutation handlers. + +## Amendment: semantic identity + +Concept and property tables each enforce uniqueness of the exact semantic IRI within their owning +ontology, including soft-deleted definitions. Different ontology modules may reuse an IRI. Ontology +IRIs have a lookup index without deployment-wide uniqueness. An IRI is compared as its original +string, including case and Unicode; Java URI equality must not normalize scheme or host case for +this check. Changing a definition's IRI requires a new definition. + +Use generated `iriHash` columns and `(ontologyId, iriHash)` unique indexes on both databases, +following the existing hashed identity lookup pattern. These are MD5 lookup keys, not integrity +proofs; reviewed content and workflow evidence continue to use SHA-256. A future IRI lookup must +compare the complete stored IRI after finding its hash. A hash collision must reject an insertion, +never resolve a different definition as the requested one. + +## Amendment: native IRI creation + +Concept and property creation may omit the IRI. Mint it once from the owning ontology's namespace +and the new entity UUID, using the existing IRI minter's namespace separator rules. Names and +labels do not participate. Imported definitions retain their explicitly supplied IRIs. PUT with +an omitted IRI preserves the existing definition's identity, including when restoring a deleted +definition. PATCH cannot remove it. Persisted entities still require an absolute IRI. + +## Amendment: client IRI fidelity + +Use the shared `ontologyIri` schema with JSON Schema `format: iri`. The generated Python model +must retain the original string; URL models normalize host/scheme casing, Unicode and escaping. +Java keeps `URI` for parsing and absolute-IRI validation, but set-valued semantic references use +lists and explicit string-based duplicate checks: Java URI equality can merge distinct semantic +identifiers. Ordered property chains permit repetition. Namespace uniqueness also compares the +exact string, without URI normalization. `test_ontology_iris.py`, `OntologyDefinitionTest` and the +ontology property/resource integration tests guard client-to-database round trips. + +## Amendment: axiom ownership + +`OntologyAxiom` belongs to an independent ontology and implements `OntologyDefinition`, as do +concepts and properties. Definitions have no individual review lifecycle. The shared ontology lock +coordinates axiom writes with release capture. Captured axiom members retain their payload after a working +edit or deletion and enforce axiom read permissions. + +Native migration 2.1.0 replaces the unreleased axiom table's generated glossary owner column and +index with `ontologyId`. Before adding the non-null generated owner column, delete only +unreleased axiom rows without an ontology owner, their relationship/history rows and development +glossary-scoped change sets containing axiom operations. This is an explicit reset of development Studio axioms; +export any wanted draft axioms before upgrading and recreate them under an ontology. +Existing native axiom rows survive replay. Glossaries, terms and configurable relations are not +modified or converted. Axiom listings filter by that ID. Concept, property and axiom listings use +`(ontologyId, deleted, name, id)` indexes to match the standard entity cursor order. Local property checks use only the owning ontology, with exact IRI +comparison after hashed property lookup. The glossary relationship-type registry does not define +ontology properties, and glossary export no longer reads independent axiom rows. Native imports +are deferred; native entity schemas expose no imports field. + +Nested expression and literal IRIs use the shared exact-IRI schema. Enumeration members retain +string-distinct identifiers in generated clients and reject exact duplicates in the authoring +guard. + +The authoring guard permits an IRI to name both a class and an individual, as specified by +[OWL 2 entity declarations](https://www.w3.org/TR/owl-primer/#Entity_Declarations). The previous +class/individual exclusion applied an OWL 1 restriction to an OWL 2 editor. Assertions and +restrictions must instead respect each declared property's object/data/annotation kind. The +profile tests, native axiom API tests and an OWL API round trip guard these rules. + +## Amendment: derived references + +Native migration 2.1.0 adds `ontology_reference` on both databases. Definition JSON remains the +source of truth. Replace a definition's reference rows in the same retained transaction and under +the same ontology lock as its write. The shared write boundary covers ordinary edits, deletion +and restoration. Native bulk/CSV authoring is deferred. A failed write rolls back both representations. +Released payloads do not depend on this working index. + +Index property domains, ranges, inverses, chains and disjointness; concept mapping targets and +schemes; and every IRI in an axiom, including nested expressions and literal datatypes. Deduplicate +only matching role and exact IRI spelling, retaining multiple roles for the same target. Iterative +expression traversal and reference collection have a combined 10,000-step limit per definition; +overflow fails the write instead of storing an incomplete index. + +Use `(sourceType, sourceId, role, iriHash)` as the primary key and index +`(ontologyId, iriHash, sourceType, sourceId, role)` for reverse lookup. SHA-256 hashes the exact IRI +bytes for this index; a hash match still requires full, case-sensitive IRI equality. No IRI lookup +resolves a definition from an unrelated module. The working-reference API pages by source type, +source UUID and role and requires read authorization on each returned source definition. This is +local reference impact, not transitive import resolution or proof of logical consistency. + +## Amendment: validation cost and hierarchy + +Store named subclass relationships only as ontology-owned `OntologyAxiom` rows. Concept reads +expose derived, read-only `superClasses` and `subClasses`; these are neither persisted nor included +in semantic digests. Reject named subclass cycles and subclass relations on SKOS-only concepts. +Class concepts cannot use individual `sameAs` mappings as a substitute for class equivalence. + +Documentation and ownership edits do not reload the complete ontology. Semantic writes read the +changed definitions, their dependent definitions, referenced declarations and subclass ancestors +through the reference index. These reads share the ontology state lock; MySQL reads that must see +recently committed changes use locking reads, including both sides of the reference/axiom join. +Cycle detection identifies every member of a strongly connected component without recursion. +An invalid unrelated draft does not prevent repairs. Capture still validates the complete model. diff --git a/docs/decisions/2026-10-14-ontology-releases-seal-canonical-sql-members.md b/docs/decisions/2026-10-14-ontology-releases-seal-canonical-sql-members.md new file mode 100644 index 000000000000..41f03bf9c8cf --- /dev/null +++ b/docs/decisions/2026-10-14-ontology-releases-seal-canonical-sql-members.md @@ -0,0 +1,89 @@ +# Ontology releases seal canonical SQL members + +- **Status:** Accepted +- **Revisions:** v1 2026-10-14 (initial); v10 2026-10-15 (single-ontology releases, captured comparison baseline, active-only projection) +- **Deciders:** Harsha +- **Guard:** `OntologyReleaseManifestTest`, `OntologyReleaseDAOTest`, `OntologyReleaseCaptureTest`, `OntologyWorkingStateTest`, `OntologyReleaseProjectionTest`, `OntologyStructureValidatorTest`, `OntologyReleaseComparisonTest`, `OntologyReleaseComparisonStorageTest`, `IndependentOntologyReleaseDiffIT`, `IndependentOntologyReleaseIT`, `IndependentOntologyReleaseExportIT`, `IndependentOntologyRetentionIT` +- **Related:** [Organizational ontology design](../plans/2026-10-14-organizational-ontology-design.md) + +## Context + +An approved ontology must survive working edits, deletion of individual definitions and ordinary +entity-history cleanup. Reviewers must decide a complete, reproducible release. SQL remains the +authoritative store; Fuseki serves the active release's asserted RDF. + +## Decision + +Use the existing `EntityDAO` JSON header and retained transactions. Store immutable member payloads +in `ontology_release_member` as MySQL `LONGTEXT` or PostgreSQL `TEXT`, addressed by release ID, kind +and entity UUID. Canonical text preserves exact decimal values and strings across both databases. +Sort object keys, preserve array order, normalize insignificant decimal trailing zeros, reject NUL +characters, and hash UTF-8 bytes with SHA-256. The member manifest binds source entity versions, +payload digests and byte counts; it excludes the release UUID and has no per-definition review revision. + +Capture freezes one ontology root and its concepts, properties and axioms. Native cross-ontology +imports, dependency tables and dependency resolution are deferred. Nonempty import requests fail +explicitly. Existing glossary import tools do not supply native release content. + +Initially bound each member to 1 MiB, each release to 10,000 members and total payloads to 64 MiB. +Capture locks `ontology_state`, checks `expectedWorkingRevision`, reads definitions in pages of 100, +and checks a cooperative 30-second budget between operations. All authoring writes share that lock. +Quota, authorization or validation failures roll back the header, members, revision and capture event. +Sealing verifies the count; sealed content cannot be edited, resealed or appended to. + +`ontology_state` stores working, release-allocation and activation revisions plus the active release +UUID. Capture records the current active release as `baseReleaseId`. The review UI passes this frozen +baseline to the diff endpoint; a later activation does not change what the reviewer compares. + +The capture gate checks structure and the supported OWL 2 DL profile. OWLAPI is test-scoped and verifies +projected models in tests. Runtime checks use the structure validator and release-scoped +`OwlProfileGuard`. Logical consistency is explicitly `NOT_RUN` until the planned Jena-fork CHECK +execution exists. Passing this gate does not prove consistency or source-data validity. + +The configured task workflow approves or rejects the whole sealed release. Working edits do not +invalidate its review. A decided release cannot be reviewed again. Decision events bind the digest, +review target, workflow and task IDs, voters and outcome, independently of Flowable history retention. +Neither capture nor approval changes the active pointer. + +`PUT /v1/ontologies/{id}/activation` requires `EditAll` and accepts an approved, sealed release owned by +that ontology with `expectedActivationRevision`. A stale revision returns 409. Activation rechecks +structural/projectability gates, changes the pointer, appends its event and enqueues the existing RDF +outbox write as the final transaction operation. Rollback activates an older approved release through +the same endpoint. An archived ontology cannot be activated. + +Fuseki contains one graph per ontology, `https://open-metadata.org/graph/ontology/{id}`, holding only +its active release. The outbox consumer rereads the active SQL pointer and replaces that graph using +Graph Store PUT, so late or repeated events cannot restore an obsolete release. Archive removes the +graph; restore and rebuild reproject the active pointer. The rebuilding RDF store journals these writes. +SQL commits before asynchronous projection; consumers must not mistake outbox lag for current RDF. + +Member reads, export, diagnostics and comparisons verify the manifest and authorize the frozen +snapshots under current policies. A missing working definition does not erase its release member. +A denied member fails the operation; header visibility alone does not authorize the complete model. + +Export supports Turtle, RDF/XML, N-Triples and JSON-LD from SQL without a running RDF store. Projection +preserves exact IRIs, OWL/SKOS concept kinds, property kinds and characteristics, ordered chains, +recursive expressions and all six supported axiom kinds. Axiom annotations preserve member provenance. +Typed enumerations use release-scoped datatype IRIs to preserve RDF-parser round trips. Units and +identifier intent are annotations, not conversions, OWL keys or source uniqueness guarantees. +Unsupported members fail export rather than disappearing. + +`GET /v1/ontologyReleases/{id}/diff?baseReleaseId=...` compares two authorized releases of the same +ontology by member kind and UUID, excluding operational and derived fields from semantic comparison. +It returns additions, changes and removals with before/after release IDs. An omitted base means empty +content, suitable for the first release. Cursors bind the release pair and sorted position; both +complete snapshots are authorized before a page is returned. This is an asserted-content comparison, +not a proof of logical equivalence. + +An ontology retaining releases cannot be hard-deleted. Check retention with a current locking read +under the same lock as capture, before cascading into working definitions. Soft deletion archives +working definitions while preserving release content and audit events. It cancels pending review +and removes the active RDF graph. Restore retains the active pointer and frozen release content. +Automated release expiry and purge policy are deferred. + +## Consequences + +No new object store, cache, broker or approval engine is required. Releases consume full-snapshot +space within explicit bounds. This is an unreleased storage contract: development databases created +with the earlier per-definition/import manifests must be reset before validating this version; do +not reinterpret an old sealed digest under the revised contract. Business glossaries are not converted. diff --git a/docs/decisions/2026-10-14-ontology-reviews-bind-workflow-tasks-to-revisions.md b/docs/decisions/2026-10-14-ontology-reviews-bind-workflow-tasks-to-revisions.md new file mode 100644 index 000000000000..afcf8b1f5edc --- /dev/null +++ b/docs/decisions/2026-10-14-ontology-reviews-bind-workflow-tasks-to-revisions.md @@ -0,0 +1,81 @@ +# Ontology releases use the existing approval workflow + +- **Status:** Accepted +- **Revisions:** v1 2026-10-14 (initial); v12 2026-10-16 (sealed-content identity and recoverable review failures) +- **Deciders:** Harsha +- **Guard:** `OntologyReviewTest`, `OntologyApprovalWorkflowTest`, `OntologyEntityDAOTest`, `IndependentOntologyApprovalIT`, `IndependentOntologyReviewRecoveryIT`, `OntologyReviewPanel.test.tsx`, `OntologyReview.spec.ts` +- **Related:** ADR:2026-10-14-ontology-definitions-have-independent-entity-storage, ADR:2026-10-14-ontology-releases-seal-canonical-sql-members + +## Context + +The approval unit is a complete immutable ontology release. A second lifecycle for each working +concept, property or axiom adds conflicting decisions and duplicates the existing workflow system. +An editable status or a governance-bot identity cannot establish approval. + +## Decision + +Use existing `RequestApproval` tasks and configured Flowable approval workflows for `OntologyRelease` +only. Reject review submissions for working ontology definitions. The server captures release UUID, +entity-history version, sealed-content digest, author and owning ontology UUID in the task's immutable +`ontologyReview` field. The task already identifies its workflow. Runtime assignments, vote thresholds and +approval steps remain in Flowable; no ontology vote table or decision endpoint is added. + +Submission requires `CreateTask` plus `EditAll` on the owning ontology. Reject a second open review +before inserting a task and recheck under the ontology lock. An approved or rejected release returns +409 on resubmission, including when active. To revise a rejected release, capture a new candidate. +Cancelling an undecided review returns its candidate to Draft and permits a new task; it never changes +an approved release or the active pointer. + +The digest format is `ontology-review-v3`. Hash only the release UUID, release revision and sealed +manifest digest. Current reviewer eligibility is checked on every vote, including earlier +voters; the author cannot approve their own release. Working edits and owner/reviewer changes do not +reset an approved release. Execute the actual runtime approval step rather than interpreting a new +workflow definition mid-process. Do not duplicate Flowable's workflow identity or maintain a second +review revision counter for immutable content. + +Resolution locks workflow, ontology state, active owning ontology and review target before consistent +reads. MySQL checks that must observe a recent commit use locking reads; bypassing application caches +alone cannot refresh a repeatable-read database snapshot. Cancellation takes ontology state before +locking the release, preserving the same lock order as writers and capture. + +The submitted entity-history version must remain readable and match the target digest. A missing +version blocks resolution and leaves recovery pending; current content cannot substitute +for lost evidence. The review UI loads that version and compares its members against captured +`baseReleaseId`, showing before/after content and removed members. Missing or denied history, diff +or selected member content disables approval. Feed cards link to review instead of offering native +one-click approval. Required rejection comments remain enforced by the existing transition UI. + +Native review submissions contain only transition, resolution and comment; they do not echo legacy +`newValue` or proposed entity JSON. No available transition means no legacy approval fallback. The +inbox preserves the returned task state after a vote, allowing multiple required votes. + +Distinguish `RESOLUTION_FAILED` from a task completion followed by `PROCESS_FAILED`. Only ontology +release decisions require successful completion of the whole workflow. Preserve the shared metric +and glossary resolution contract. Multi-vote glossary approval retains deadlock retry around the +whole engine command with fresh variables on each attempt. + +Persist a typed decision receipt in the Flowable transaction that records the decisive vote. A receipt +can be applied only after committed successful process history: an end time, no deletion/failure +reason and no error end. Intermediate stages accumulate voters without deciding the release. + +Use the existing `entity_extension` table for the `ontology.reviewExecution` recovery marker and its +existing extension index. The managed reconciler checks at most 100 markers per pass, starts after +10 seconds and repeats every 30 seconds with cursor wraparound. It can recover the execution ID by +workflow key and task UUID. A recorded start failure cancels the review; a task with no execution +gets five minutes to start before cancellation. Existing workflows are not timed out by that grace. + +Applying a valid receipt updates task resolution, release decision, durable release event and marker +in one retained entity transaction with fresh reads. A crash after the engine commit leaves the marker +for retry. Missing or archived targets and ineligible reviewers leave recovery pending for repair; +validation or authorization failures never irreversibly cancel a review. Explicit workflow failure or +cancellation terminates the workflow instance tree; its marker stays until execution cleanup finishes. Closed +reviews cannot be reopened or mutated, and active review tasks cannot be deleted. Repeated cancellation +returns the first result without rewriting its actor, timestamp or comment. + +## Consequences + +Working definitions retain normal history and optimistic write checks with no individual review +fields. Release review, activation and metadata assignment are distinct operations. Recovery handles +the existing separate Flowable and entity transactions; it is not another approval engine. Native +bulk/CSV authoring and cross-ontology imports are deferred. Glossary relationships and approval policy +retain their existing behavior. diff --git a/docs/decisions/2026-10-15-fuseki-holds-only-the-active-ontology-release.md b/docs/decisions/2026-10-15-fuseki-holds-only-the-active-ontology-release.md new file mode 100644 index 000000000000..b6aba7038a58 --- /dev/null +++ b/docs/decisions/2026-10-15-fuseki-holds-only-the-active-ontology-release.md @@ -0,0 +1,62 @@ +# Fuseki holds only the active ontology release + +- **Status:** Accepted +- **Revisions:** v1 2026-10-15 (initial) · v2 2026-10-17 (rebuild excludes live drain) +- **Deciders:** Harsha +- **Guard:** `IndependentOntologyActivationIT`, `OntologyReleaseActivationTest`, `OntologyActiveGraphStorageTest#rebuildCannotOverwriteAnActivationDeliveredWhileItsModelWasBeingBuilt`, `RdfOntologyActivationIT`, `RdfBlueGreenRebuildIT`, `RdfLiveWriteRecoveryIT`, reviewer +- **Related:** [Organizational ontology design](../plans/2026-10-14-organizational-ontology-design.md), ADR:2026-10-14-ontology-releases-seal-canonical-sql-members + +## Context + +Working definitions, review candidates and older releases have different governance states. +Projecting all of them into the serving RDF dataset would mix meanings that an organization has +not selected. SQL already retains immutable release members and the active release pointer. + +## Decision + +Keep one asserted graph per ontology at `https://open-metadata.org/graph/ontology/{id}`. It contains +only that ontology's active release. Working edits and approval alone do not change it. Historical +releases remain readable and exportable from SQL, without separate serving graphs. + +Activation requires edit permission on the ontology, a sealed release owned by it, completed +independent workflow approval, and successful structural and supported OWL 2 DL validation of its +pinned closure. Logical consistency is explicitly `NOT_RUN` until the remote Jena `CHECK` path is +available. The in-server gate does not run an OWL reasoner; OWLAPI remains a test dependency. + +Compare the caller's expected activation revision under the ontology state lock. A mismatch is +409. Commit the pointer, incremented revision, append-only activation event, and durable RDF command +in one SQL transaction. Enqueue the RDF command last to retain queue commit order. Rollback uses +the same operation with an older approved release and produces another activation event. + +The command carries the ontology ID and reads the current active pointer when applied. Replace the +whole graph atomically with Graph Store PUT, including an empty graph for an archived/deleted owner. +Restoration reprojects the retained active release. Graph replacement participates in the existing +rebuild journal so an activation during a rebuild survives promotion. Rebuilds reconstruct active +graphs from SQL and remove orphan ontology graphs. + +RDF-disabled installations can activate releases. Enabling RDF and rebuilding projects their active +content. An unavailable Fuseki does not roll back a committed activation: the durable queue retries +and existing projection health describes lag or failure. + +## Consequences + +SQL is the publication authority. Fuseki is a rebuildable serving projection, with eventual delivery +and atomic per-graph replacement. The union default graph still combines IRIs shared by different +active ontologies; callers needing module isolation use a named `GRAPH`. + +Older servers cannot decode the new live-write command during a mixed-version deployment; complete +the upgrade and rebuild any dead-lettered projection. Raising release/projection size limits needs +measured capture, journal and Fuseki capacity evidence. Activation checks the existing 16 MiB +encoded RDF journal limit before committing, including when RDF is disabled, so every activated +release remains replayable during a later rebuild. + +## Amendment v2 — 2026-10-17: rebuild excludes live drain + +`OntologyActiveGraph.rebuild` refreshes each ontology graph while holding the +`rdf_live_write_guard` 'drain' row lock through `RdfLiveWriteStore.withWriterLock`. It acquires the +lock with blocking `FOR UPDATE` before reading the active release and holds it through graph +replacement. Rebuilds must wait for this lock rather than skip a refresh. + +The live drain acquires the same row with `FOR UPDATE SKIP LOCKED` and defers delivery while a +rebuild holds it. Activation can still commit and enqueue a write during that refresh; delivery +then follows the rebuild's replacement, so an older release cannot overwrite the newer activation. diff --git a/docs/decisions/2026-10-16-native-ontologies-share-studio-and-memory-review.md b/docs/decisions/2026-10-16-native-ontologies-share-studio-and-memory-review.md new file mode 100644 index 000000000000..6cb82cfbbddf --- /dev/null +++ b/docs/decisions/2026-10-16-native-ontologies-share-studio-and-memory-review.md @@ -0,0 +1,44 @@ +# Native ontologies share Studio and memory review + +- **Status:** Accepted +- **Revisions:** v1 2026-10-16 (initial) +- **Deciders:** Product owner +- **Guard:** IndependentOntologyReleaseIT, IndependentOntologyActivationIT, ontology change-set integration tests, OntologyReview browser specs +- **Related:** ADR:2026-10-14-ontology-definitions-have-independent-entity-storage; ADR:2026-10-14-ontology-releases-seal-canonical-sql-members + +## Context + +Replacing the glossary Studio removed existing AI, memory review, library, import/export, leases, +query builder and exploration workflows. Native definitions need those entry points to coexist +with the glossary model. A memory proposal edits a working model; publication still requires an +independent review of the whole sealed release. + +## Decision + +Keep main's Studio and add native selection with Model and Releases surfaces. Glossary relations +continue to use their current entities and graph. Formal axiom authoring selects a native ontology. + +A change set targets either its existing glossary scope or one native ontology, never both. Native +operations carry typed concept, property or axiom payloads and source-memory references. Applying +them holds the same per-ontology lock as ordinary edits and capture, checks the working revision, +and validates the resulting model atomically through `OntologyWorkingState`. Memory proposal review +uses the existing Studio panel, including Context Center's `?draft=` links. Applying a proposal +updates working content; it does not approve or activate a release. + +Release encoding `ontology-release-v1` explicitly selects identity and semantic fields. Audit and +permission fields are not sealed content; readers tolerate unknown historical fields, while the +manifest still verifies the original bytes. Current ownership is used for authorization. A review +binds only the release id, release revision and immutable content digest. Temporary authorization +or availability failures remain retryable, rather than cancelling a review. + +Native SPARQL reads the verified SQL projection of the active release. It uses the same assertions +as the active Fuseki graph, remains consistent while an outbox write is pending, and requires view +access to every captured member. It cannot read working edits, other graphs or external services. + +## Consequences + +The SQL model, approval task and active pointer remain authoritative. General field-approval staging +must not hold native working edits: the release is their publication approval unit. Native metadata +labels, data exploration through those labels, library installation, import, AI generation and +visual query generation require explicit native adapters; preserving their glossary workflows does +not claim those adapters are complete. The capability matrix records their delivery status. diff --git a/docs/generated/api-reference.md b/docs/generated/api-reference.md index 270b58d43457..8eec0584e8c9 100644 --- a/docs/generated/api-reference.md +++ b/docs/generated/api-reference.md @@ -13,7 +13,7 @@ hand-edit; run `make generate-api-reference` (or `make generate-reference-docs`) - Source is the annotations, **not** `openapi.yml` (a config stub with no endpoints; the full spec is assembled at runtime by Dropwizard). -**1922 endpoints** across 75 resource packages · 1912 carry a summary. +**1981 endpoints** across 75 resource packages · 1971 carry a summary. ## (root) @@ -381,6 +381,7 @@ hand-edit; run `make generate-api-reference` (or `make generate-reference-docs`) | `DELETE` | `/v1/contextCenter/memories/{id}` | Delete a memory by id | | `GET` | `/v1/contextCenter/memories/{id}` | Get a memory by id | | `PATCH` | `/v1/contextCenter/memories/{id}` | Update a memory | +| `PUT` | `/v1/contextCenter/memories/{id}/outcomes` | Record a memory's outcome counters | | `DELETE` | `/v1/contextCenter/memories/{id}/pin` | Unpin a memory | | `PUT` | `/v1/contextCenter/memories/{id}/pin` | Pin a memory | | `GET` | `/v1/contextCenter/memories/{id}/versions` | List context memory versions | @@ -394,6 +395,7 @@ hand-edit; run `make generate-api-reference` (or `make generate-reference-docs`) | `GET` | `/v1/csvAsyncJobs` | List CSV import and export jobs | | `GET` | `/v1/csvAsyncJobs/{jobId}` | Get a CSV import or export job | | `PUT` | `/v1/csvAsyncJobs/{jobId}/cancel` | Cancel a CSV import or export job | +| `GET` | `/v1/csvAsyncJobs/{jobId}/import-result` | Get the result of a completed import job | | `GET` | `/v1/csvAsyncJobs/{jobId}/result` | Download the CSV produced by a completed export job | ## dashboards @@ -1118,6 +1120,15 @@ hand-edit; run `make generate-api-reference` (or `make generate-reference-docs`) | Method | Path | Purpose | |---|---|---| +| `GET` | `/v1/changeRequests` | List change requests for an asset or a requester | +| `POST` | `/v1/changeRequests/preview/{entityType}/{id}` | Preview whether a PATCH would be held for approval, without saving it | +| `GET` | `/v1/changeRequests/{id}` | Get a change request with its active revision | +| `POST` | `/v1/changeRequests/{id}/cancel` | Cancel a pending change request (admin only) | +| `GET` | `/v1/changeRequests/{id}/decisions` | List the review decisions recorded on a change request, oldest first | +| `GET` | `/v1/changeRequests/{id}/events` | List the lifecycle history of a change request, in order | +| `POST` | `/v1/changeRequests/{id}/override` | Publish a pending change request without review (admin only) | +| `GET` | `/v1/changeRequests/{id}/revisions` | List every revision of a change request, oldest first | +| `POST` | `/v1/changeRequests/{id}/withdraw` | Withdraw your own pending change request | | `GET` | `/v1/governance/intakeForms` | List IntakeForms | | `POST` | `/v1/governance/intakeForms` | Create a new IntakeForm | | `PUT` | `/v1/governance/intakeForms` | Create or update an IntakeForm | @@ -1143,7 +1154,7 @@ hand-edit; run `make generate-api-reference` (or `make generate-reference-docs`) | `DELETE` | `/v1/governance/workflowDefinitions/{id}` | Delete a Workflow Definition by Id | | `GET` | `/v1/governance/workflowDefinitions/{id}` | Get a Workflow Definition by Id | | `PATCH` | `/v1/governance/workflowDefinitions/{id}` | Update a Workflow Definition by Id | -| `POST` | `/v1/governance/workflowDefinitions/{id}/redeploy` | Get a Workflow Definition by Id | +| `POST` | `/v1/governance/workflowDefinitions/{id}/redeploy` | Redeploy a Workflow Definition | | `GET` | `/v1/governance/workflowDefinitions/{id}/versions` | List Workflow Definition versions | | `GET` | `/v1/governance/workflowDefinitions/{id}/versions/{version}` | Get a version of the Workflow Definition | | `GET` | `/v1/governance/workflowInstanceStates` | List the Workflow Instance States | @@ -1334,6 +1345,22 @@ hand-edit; run `make generate-api-reference` (or `make generate-reference-docs`) | Method | Path | Purpose | |---|---|---| +| `GET` | `/v1/ontologies` | List ontology definitions | +| `POST` | `/v1/ontologies` | Create an ontology | +| `PUT` | `/v1/ontologies` | Create or update an ontology | +| `DELETE` | `/v1/ontologies/name/{fqn}` | Delete an ontology definition by name | +| `GET` | `/v1/ontologies/name/{fqn}` | Get an ontology by name | +| `PUT` | `/v1/ontologies/restore` | Restore an ontology | +| `DELETE` | `/v1/ontologies/{id}` | Delete an ontology | +| `GET` | `/v1/ontologies/{id}` | Get an ontology | +| `PATCH` | `/v1/ontologies/{id}` | Patch an ontology | +| `PUT` | `/v1/ontologies/{id}/activation` | Activate an approved ontology release or roll back to an older release | +| `GET` | `/v1/ontologies/{id}/references` | Find working definitions that reference an exact IRI in this ontology | +| `POST` | `/v1/ontologies/{id}/releases` | Capture immutable ontology release content | +| `POST` | `/v1/ontologies/{id}/sparql` | Query the active ontology release | +| `GET` | `/v1/ontologies/{id}/state` | Get ontology working and activation revisions | +| `GET` | `/v1/ontologies/{id}/versions` | List ontology versions | +| `GET` | `/v1/ontologies/{id}/versions/{version}` | Get an ontology version | | `POST` | `/v1/ontology/ai/drafts` | Generate a reviewable ontology change-set Draft | | `POST` | `/v1/ontology/ai/mappings/suggestions` | Propose reviewable external concept mappings | | `POST` | `/v1/ontology/ai/memories/jobs` | Queue a reviewable glossary draft from published memories | @@ -1380,6 +1407,17 @@ hand-edit; run `make generate-api-reference` (or `make generate-reference-docs`) | `POST` | `/v1/ontologyChangeSets/{id}/undo` | Move the draft cursor backward | | `GET` | `/v1/ontologyChangeSets/{id}/versions` | List change-set versions | | `GET` | `/v1/ontologyChangeSets/{id}/versions/{version}` | Get a change-set version | +| `GET` | `/v1/ontologyConcepts` | List ontology concept definitions | +| `POST` | `/v1/ontologyConcepts` | Create an ontology concept | +| `PUT` | `/v1/ontologyConcepts` | Create or update an ontology concept | +| `DELETE` | `/v1/ontologyConcepts/name/{fqn}` | Delete an ontology definition by name | +| `GET` | `/v1/ontologyConcepts/name/{fqn}` | Get an ontology concept by name | +| `PUT` | `/v1/ontologyConcepts/restore` | Restore an ontology concept | +| `DELETE` | `/v1/ontologyConcepts/{id}` | Delete an ontology concept | +| `GET` | `/v1/ontologyConcepts/{id}` | Get an ontology concept | +| `PATCH` | `/v1/ontologyConcepts/{id}` | Patch an ontology concept | +| `GET` | `/v1/ontologyConcepts/{id}/versions` | List ontology concept versions | +| `GET` | `/v1/ontologyConcepts/{id}/versions/{version}` | Get an ontology concept version | | `POST` | `/v1/ontologyEditLocks/acquire` | Acquire an authoring lease | | `PUT` | `/v1/ontologyEditLocks/renew` | Renew an authoring lease | | `DELETE` | `/v1/ontologyEditLocks/{resourceType}/{resourceId}` | Release an authoring lease | @@ -1387,6 +1425,26 @@ hand-edit; run `make generate-api-reference` (or `make generate-reference-docs`) | `GET` | `/v1/ontologyPacks` | List ontology library packs | | `GET` | `/v1/ontologyPacks/{packId}` | Get an ontology library pack | | `POST` | `/v1/ontologyPacks/{packId}/install` | Dry-run or install selected ontology pack modules | +| `GET` | `/v1/ontologyProperties` | List ontology property definitions | +| `POST` | `/v1/ontologyProperties` | Create an ontology property | +| `PUT` | `/v1/ontologyProperties` | Create or update an ontology property | +| `DELETE` | `/v1/ontologyProperties/name/{fqn}` | Delete an ontology definition by name | +| `GET` | `/v1/ontologyProperties/name/{fqn}` | Get an ontology property by name | +| `PUT` | `/v1/ontologyProperties/restore` | Restore an ontology property | +| `DELETE` | `/v1/ontologyProperties/{id}` | Delete an ontology property | +| `GET` | `/v1/ontologyProperties/{id}` | Get an ontology property | +| `PATCH` | `/v1/ontologyProperties/{id}` | Patch an ontology property | +| `GET` | `/v1/ontologyProperties/{id}/versions` | List ontology property versions | +| `GET` | `/v1/ontologyProperties/{id}/versions/{version}` | Get an ontology property version | +| `GET` | `/v1/ontologyReleases` | List sealed ontology releases | +| `GET` | `/v1/ontologyReleases/{id}` | Get a sealed ontology release | +| `GET` | `/v1/ontologyReleases/{id}/diff` | Compare definitions from two sealed releases | +| `GET` | `/v1/ontologyReleases/{id}/events` | Read durable release capture and workflow decisions | +| `GET` | `/v1/ontologyReleases/{id}/export` | Export the asserted content of a sealed ontology release | +| `GET` | `/v1/ontologyReleases/{id}/members/{kind}` | Page through one release's immutable members | +| `GET` | `/v1/ontologyReleases/{id}/members/{kind}/{memberId}` | Read an immutable release member | +| `GET` | `/v1/ontologyReleases/{id}/structure` | Check property declarations and axiom structure in a sealed release | +| `GET` | `/v1/ontologyReleases/{id}/versions/{version}` | Read the release header version submitted to an approval workflow | | `GET` | `/v1/relationshipTypes` | List relationship types | | `POST` | `/v1/relationshipTypes` | Create a relationship type | | `PUT` | `/v1/relationshipTypes` | Create or update a relationship type | @@ -2287,6 +2345,7 @@ hand-edit; run `make generate-api-reference` (or `make generate-reference-docs`) | `PATCH` | `/v1/metadata/types/name/{fqn}` | Update a type using name. | | `DELETE` | `/v1/metadata/types/name/{name}` | Delete a type by name | | `GET` | `/v1/metadata/types/name/{name}` | Get a type by name | +| `GET` | `/v1/metadata/types/workflows/approvalEnforcedEntityTypes` | Get entity types whose edits are held for approval | | `DELETE` | `/v1/metadata/types/{id}` | Delete a type by id | | `GET` | `/v1/metadata/types/{id}` | Get a type | | `PATCH` | `/v1/metadata/types/{id}` | Update a type | diff --git a/docs/generated/entity-index.md b/docs/generated/entity-index.md index 46a04bf4c5ec..7e57771f8084 100644 --- a/docs/generated/entity-index.md +++ b/docs/generated/entity-index.md @@ -14,7 +14,7 @@ hand-edit; run `make generate-entity-index` (or `make generate-reference-docs`). - **REST resource** is joined from `extends EntityResource`; `—` means no dedicated `EntityResource` was found (the entity may be exposed via a shared or non-`EntityResource` route). -**87 entities** · 62 with a dedicated `EntityResource`. +**91 entities** · 66 with a dedicated `EntityResource`. ## entity/(root) @@ -94,8 +94,12 @@ hand-edit; run `make generate-entity-index` (or `make generate-reference-docs`). | Metric | `openmetadata-spec/src/main/resources/json/schema/entity/data/metric.json` | `org.openmetadata.schema.entity.data.Metric` | `metadata.generated.schema.entity.data.metric` | `openmetadata-ui/src/main/resources/ui/src/generated/entity/data/metric.ts` | `org.openmetadata.service.resources.metrics.MetricResource` | | MetricGroup | `openmetadata-spec/src/main/resources/json/schema/entity/data/metricGroup.json` | `org.openmetadata.schema.entity.data.MetricGroup` | `metadata.generated.schema.entity.data.metricGroup` | `openmetadata-ui/src/main/resources/ui/src/generated/entity/data/metricGroup.ts` | `org.openmetadata.service.resources.metrics.MetricGroupResource` | | MlModel | `openmetadata-spec/src/main/resources/json/schema/entity/data/mlmodel.json` | `org.openmetadata.schema.entity.data.MlModel` | `metadata.generated.schema.entity.data.mlmodel` | `openmetadata-ui/src/main/resources/ui/src/generated/entity/data/mlmodel.ts` | `org.openmetadata.service.resources.mlmodels.MlModelResource` | +| Ontology | `openmetadata-spec/src/main/resources/json/schema/entity/data/ontology.json` | `org.openmetadata.schema.entity.data.Ontology` | `metadata.generated.schema.entity.data.ontology` | `openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontology.ts` | `org.openmetadata.service.resources.ontology.OntologyResource` | | OntologyAxiom | `openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyAxiom.json` | `org.openmetadata.schema.entity.data.OntologyAxiom` | `metadata.generated.schema.entity.data.ontologyAxiom` | `openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyAxiom.ts` | `org.openmetadata.service.resources.ontology.OntologyAxiomResource` | | OntologyChangeSet | `openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyChangeSet.json` | `org.openmetadata.schema.entity.data.OntologyChangeSet` | `metadata.generated.schema.entity.data.ontologyChangeSet` | `openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyChangeSet.ts` | `org.openmetadata.service.resources.ontology.OntologyChangeSetResource` | +| OntologyConcept | `openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyConcept.json` | `org.openmetadata.schema.entity.data.OntologyConcept` | `metadata.generated.schema.entity.data.ontologyConcept` | `openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyConcept.ts` | `org.openmetadata.service.resources.ontology.OntologyConceptResource` | +| OntologyProperty | `openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyProperty.json` | `org.openmetadata.schema.entity.data.OntologyProperty` | `metadata.generated.schema.entity.data.ontologyProperty` | `openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyProperty.ts` | `org.openmetadata.service.resources.ontology.OntologyPropertyResource` | +| OntologyRelease | `openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyRelease.json` | `org.openmetadata.schema.entity.data.OntologyRelease` | `metadata.generated.schema.entity.data.ontologyRelease` | `openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyRelease.ts` | `org.openmetadata.service.resources.ontology.OntologyReleaseResource` | | Page | `openmetadata-spec/src/main/resources/json/schema/entity/data/page.json` | `org.openmetadata.schema.entity.data.Page` | `metadata.generated.schema.entity.data.page` | `openmetadata-ui/src/main/resources/ui/src/generated/entity/data/page.ts` | `org.openmetadata.service.resources.knowledge.KnowledgePageResource` | | PageHierarchy | `openmetadata-spec/src/main/resources/json/schema/entity/data/pageHierarchy.json` | `org.openmetadata.schema.entity.data.PageHierarchy` | `metadata.generated.schema.entity.data.pageHierarchy` | `openmetadata-ui/src/main/resources/ui/src/generated/entity/data/pageHierarchy.ts` | — | | Pipeline | `openmetadata-spec/src/main/resources/json/schema/entity/data/pipeline.json` | `org.openmetadata.schema.entity.data.Pipeline` | `metadata.generated.schema.entity.data.pipeline` | `openmetadata-ui/src/main/resources/ui/src/generated/entity/data/pipeline.ts` | `org.openmetadata.service.resources.pipelines.PipelineResource` | diff --git a/docs/index.md b/docs/index.md index f7f6c0ebce77..85a6e62f690b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -58,7 +58,9 @@ index, so only the entry point is listed here. A comment citing `ADR:-- { + Task ready = SdkClients.adminClient().tasks().get(task.getId().toString()); + assertNotNull(ready.getWorkflowInstanceId()); + assertEquals(2, ready.getAvailableTransitions().size()); + }); + return SdkClients.adminClient().tasks().get(task.getId().toString()); + } + + protected static CreateTask reviewRequest(TestNamespace ns, String type, String fqn) { + return new CreateTask() + .withName(ns.prefix("review")) + .withCategory(TaskCategory.Approval) + .withType(TaskEntityType.RequestApproval) + .withAbout("<#E::" + type + "::" + fqn + ">"); + } + + protected static ResolveTask decision(TaskResolutionType type) { + return new ResolveTask().withResolutionType(type).withComment("Ontology review decision"); + } + + protected static OntologyRelease current(OntologyRelease release) { + return SdkClients.adminClient().ontologyReleases().get(release.getId()); + } + + protected static Ontology current(Ontology ontology) { + return SdkClients.adminClient() + .ontologies() + .get(ontology.getId().toString(), "owners,reviewers"); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseOntologyIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseOntologyIT.java new file mode 100644 index 000000000000..70f71a01dbc6 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseOntologyIT.java @@ -0,0 +1,216 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.openmetadata.it.util.NamespaceCleanup; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.schema.OntologyEntityInterface; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.services.EntityServiceBase; +import org.openmetadata.service.Entity; +import org.openmetadata.service.governance.workflows.OntologyReviewReconciler; +import org.openmetadata.service.jdbi3.TaskRepository; + +abstract class BaseOntologyIT extends BaseEntityIT { + protected abstract EntityServiceBase entityService(); + + protected BaseOntologyIT() { + supportsEntityStatus = false; + supportsFollowers = false; + supportsTags = false; + supportsDomains = false; + supportsDataProducts = false; + supportsCustomExtension = false; + supportsSearchIndex = false; + } + + protected static URI definitionIri(String name, TestNamespace ns) { + return URI.create( + "https://example.org/ontology/" + + ns.shortPrefix() + + "/" + + URLEncoder.encode(name, StandardCharsets.UTF_8)); + } + + @Override + protected T getEntityWithFields(String id, String fields) { + return entityService().get(id, fields); + } + + @Override + protected T getEntityByNameWithFields(String name, String fields) { + return entityService().getByName(name, fields); + } + + @Override + protected T getEntityIncludeDeleted(String id) { + return entityService().get(id, null, "deleted"); + } + + @Override + protected EntityHistory getVersionHistory(UUID id) { + return entityService().getVersionList(id); + } + + @Override + protected T getVersion(UUID id, Double version) { + return entityService().getVersion(id.toString(), version); + } + + @AfterEach + void cleanup(TestNamespace ns) { + deleteRoots(ns); + } + + static void deleteRoots(TestNamespace ns) { + for (var root : ns.drainTrackedRoots().reversed().stream().distinct().toList()) { + if (!Entity.ONTOLOGY.equals(root.entityType()) && !Entity.TASK.equals(root.entityType())) { + NamespaceCleanup.deleteRoots(List.of(root)); + continue; + } + var client = SdkClients.adminClient(); + var service = + switch (root.entityType()) { + case Entity.ONTOLOGY -> client.ontologies(); + case Entity.TASK -> client.tasks(); + default -> throw new IllegalArgumentException("Unexpected root: " + root.entityType()); + }; + if (Entity.TASK.equals(root.entityType())) { + closeReview(root.id()); + } + final boolean retained = + Entity.ONTOLOGY.equals(root.entityType()) + && Entity.getCollectionDAO().ontologyReleaseDAO().hasReleases(root.id()); + if (retained + && Boolean.TRUE.equals(service.get(root.id().toString(), null, "all").getDeleted())) { + continue; + } + service.delete( + root.id().toString(), + Map.of("hardDelete", Boolean.toString(!retained), "recursive", "true")); + } + } + + private static void closeReview(UUID id) { + Task task = SdkClients.adminClient().tasks().get(id.toString()); + if (!TaskRepository.isTerminalStatus(task.getStatus()) && task.getResolution() == null) { + SdkClients.adminClient().tasks().close(id.toString()); + } + OntologyReviewReconciler reconciler = new OntologyReviewReconciler(Entity.getCollectionDAO()); + Awaitility.await() + .atMost(Duration.ofSeconds(30)) + .until( + () -> { + reconciler.reconcile(id); + return Entity.getCollectionDAO() + .entityExtensionDAO() + .getExtension(id, OntologyReviewReconciler.EXTENSION) + == null; + }); + } + + @Test + void nativeDefinitionIriSurvivesUpdatesAndRestoration(TestNamespace ns) { + assumeFalse(Entity.ONTOLOGY.equals(getEntityType())); + K request = createMinimalRequest(ns); + setFieldViaReflection(request, "setIri", URI.class, null); + T entity = createEntity(request); + var ontology = + SdkClients.adminClient().ontologies().get(entity.getOntology().getId().toString()); + assertEquals(ontology.getNamespace().toString() + entity.getId(), entity.getIri().toString()); + ontology.setNamespace(URI.create(ontology.getNamespace() + "new-definitions/")); + SdkClients.adminClient().ontologies().update(ontology.getId(), ontology); + + setFieldViaReflection(request, "setDescription", String.class, "Revised native definition"); + SdkClients.adminClient() + .getHttpClient() + .executeForString(HttpMethod.PUT, getResourcePath(), request); + + T updated = getEntity(entity.getId().toString()); + assertEquals(entity.getIri().toString(), updated.getIri().toString()); + assertEquals("Revised native definition", updated.getDescription()); + + deleteEntity(entity.getId().toString()); + final T deleted = getEntityIncludeDeleted(entity.getId().toString()); + setFieldViaReflection(request, "setIri", URI.class, URI.create("urn:changed-identity")); + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.adminClient() + .getHttpClient() + .executeForString(HttpMethod.PUT, getResourcePath(), request)) + .getStatusCode()); + assertEquals( + deleted.getVersion(), getEntityIncludeDeleted(entity.getId().toString()).getVersion()); + setFieldViaReflection(request, "setIri", URI.class, null); + SdkClients.adminClient() + .getHttpClient() + .executeForString(HttpMethod.PUT, getResourcePath(), request); + assertEquals( + entity.getIri().toString(), getEntity(entity.getId().toString()).getIri().toString()); + } + + @Test + void semanticIriCannotBeCleared(TestNamespace ns) { + T entity = createEntity(createMinimalRequest(ns)); + var patch = JsonUtils.getObjectMapper().createArrayNode(); + patch.addObject().put("op", "remove").put("path", "/iri"); + OpenMetadataException rejected = + assertThrows( + OpenMetadataException.class, () -> entityService().patch(entity.getId(), patch)); + assertEquals(400, rejected.getStatusCode()); + assertEquals( + entity.getIri().toString(), getEntity(entity.getId().toString()).getIri().toString()); + } + + @Test + void semanticIdentityIsScopedToItsOntology(TestNamespace ns) { + T entity = createEntity(createMinimalRequest(ns)); + K duplicate = createRequest(ns.prefix("duplicateIri"), ns); + setFieldViaReflection(duplicate, "setIri", URI.class, entity.getIri()); + if (Entity.ONTOLOGY.equals(getEntityType())) { + assertNotEquals(entity.getId(), createEntity(duplicate).getId()); + } else { + OpenMetadataException conflict = + assertThrows(OpenMetadataException.class, () -> createEntity(duplicate)); + assertEquals(409, conflict.getStatusCode()); + } + } + + @Test + void semanticIriCannotBeChangedEvenBySchemeCase(TestNamespace ns) { + T entity = createEntity(createMinimalRequest(ns)); + var patch = JsonUtils.getObjectMapper().createArrayNode(); + patch + .addObject() + .put("op", "replace") + .put("path", "/iri") + .put("value", entity.getIri().toString().replace("https:", "HTTPS:")); + OpenMetadataException rejected = + assertThrows( + OpenMetadataException.class, () -> entityService().patch(entity.getId(), patch)); + assertEquals(400, rejected.getStatusCode()); + assertEquals( + entity.getIri().toString(), getEntity(entity.getId().toString()).getIri().toString()); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ChangeRequestImportAndBulkIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ChangeRequestImportAndBulkIT.java index e3a9c529d773..37f3dd62974b 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ChangeRequestImportAndBulkIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ChangeRequestImportAndBulkIT.java @@ -448,12 +448,13 @@ void heldDataProductRemovalKeepsLineageAndOutputPortUntilApproved(TestNamespace .withToEntity(downstream.getEntityReference()))); admin.dataProducts().bulkAddOutputPorts(source.getFullyQualifiedName(), assets(held)); assertTrue(lineageEdgeExists(source, target), "the products' lineage follows their tables'"); - deployHookWorkflowFor( + deployWorkflowAssigning( ns, Entity.TABLE, "\"dataProducts\"", "", - filterScopedTo(Entity.TABLE, held.getFullyQualifiedName())); + filterScopedTo(Entity.TABLE, held.getFullyQualifiedName()), + SharedEntities.get().USER1.getEntityReference()); BulkOperationResult removal = admin.dataProducts().bulkRemoveAssets(source.getFullyQualifiedName(), assets(held)); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyActivationIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyActivationIT.java new file mode 100644 index 000000000000..90b0146ba8a1 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyActivationIT.java @@ -0,0 +1,249 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.api.data.ActivateOntologyRelease; +import org.openmetadata.schema.api.data.CreateOntologyConcept; +import org.openmetadata.schema.api.rdf.SparqlQuery; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyReleaseEvent; +import org.openmetadata.schema.type.OntologyReleaseEvent.EventType; +import org.openmetadata.schema.type.OntologyState; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.sdk.client.OpenMetadataClient; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.service.Entity; + +@Execution(ExecutionMode.CONCURRENT) +@ExtendWith(TestNamespaceExtension.class) +public class IndependentOntologyActivationIT extends BaseOntologyApprovalIT { + @Test + void nonAdminQueriesOnlyTheActiveRelease(TestNamespace ns) { + final var client = SdkClients.adminClient(); + final var ontology = createOntology(ns); + final var concept = + client + .ontologyConcepts() + .create( + new CreateOntologyConcept() + .withName("Published") + .withOntology(ontology.getFullyQualifiedName()) + .withConceptType(OntologyConceptType.OWL_CLASS)); + final var request = + new SparqlQuery() + .withQuery("SELECT ?class WHERE { ?class a }"); + final String path = "/v1/ontologies/" + ontology.getId() + "/sparql"; + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.user2Client() + .getHttpClient() + .execute(HttpMethod.POST, path, request, JsonNode.class)) + .getStatusCode()); + final var release = capture(ontology); + approve(ns, release); + activate(client, ontology, release, 0); + client + .ontologyConcepts() + .create( + new CreateOntologyConcept() + .withName("WorkingOnly") + .withOntology(ontology.getFullyQualifiedName()) + .withConceptType(OntologyConceptType.OWL_CLASS)); + + final var result = + SdkClients.user2Client() + .getHttpClient() + .execute(HttpMethod.POST, path, request, JsonNode.class); + assertEquals(1, result.path("results").path("bindings").size()); + assertEquals( + concept.getIri().toString(), + result.path("results").path("bindings").get(0).path("class").path("value").asText()); + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.user2Client() + .getHttpClient() + .execute( + HttpMethod.POST, + path, + new SparqlQuery() + .withQuery( + "SELECT * WHERE { SERVICE { ?s ?p ?o } }"), + JsonNode.class)); + } + + @Test + void approvalActivationAndRollbackAreSeparateAuditedTransitions(TestNamespace ns) { + final var client = SdkClients.adminClient(); + final var ontology = createOntology(ns); + final var first = capture(ontology); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> activate(client, ontology, first, 0)) + .getStatusCode()); + approve(ns, first); + assertNull(state(ontology).getActiveReleaseId()); + final var initial = activate(client, ontology, first, 0); + assertEquals(first.getId(), initial.getActiveReleaseId()); + assertEquals(1L, initial.getActivationRevision()); + assertEquals( + 409, + assertThrows( + OpenMetadataException.class, + () -> + client + .tasks() + .create( + reviewRequest( + ns, Entity.ONTOLOGY_RELEASE, first.getFullyQualifiedName()))) + .getStatusCode()); + assertEquals(first.getId(), state(ontology).getActiveReleaseId()); + assertEquals(1L, state(ontology).getActivationRevision()); + + client + .ontologies() + .update(ontology.getId(), current(ontology).withDescription("Working revision two")); + final var second = capture(ontology); + approve(ns, second); + assertEquals(first.getId(), state(ontology).getActiveReleaseId()); + assertEquals( + 409, + assertThrows(OpenMetadataException.class, () -> activate(client, ontology, second, 0)) + .getStatusCode()); + assertEquals(second.getId(), activate(client, ontology, second, 1).getActiveReleaseId()); + final var rollback = activate(client, ontology, first, 2); + assertEquals(first.getId(), rollback.getActiveReleaseId()); + assertEquals(3L, rollback.getActivationRevision()); + final var activationEvents = + events(first).stream() + .filter(event -> event.getEventType() == EventType.ACTIVATED) + .toList(); + assertEquals( + List.of(1L, 3L), + activationEvents.stream().map(OntologyReleaseEvent::getActivationRevision).toList()); + assertNull(activationEvents.getFirst().getPreviousReleaseId()); + assertEquals(second.getId(), activationEvents.getLast().getPreviousReleaseId()); + assertEquals( + List.of(EventType.CAPTURED, EventType.APPROVED, EventType.ACTIVATED, EventType.ACTIVATED), + events(first).stream().map(event -> event.getEventType()).toList()); + assertEquals( + first.getContentDigest(), client.ontologyReleases().get(first.getId()).getContentDigest()); + } + + @Test + void activationRequiresOwnerEditPermissionAndAMatchingLiveOntology(TestNamespace ns) { + final var client = SdkClients.adminClient(); + final var ontology = createOntology(ns); + final var other = createOntology(ns, "other"); + final var release = capture(ontology); + approve(ns, release); + assertEquals( + 403, + assertThrows( + OpenMetadataException.class, + () -> activate(SdkClients.user2Client(), ontology, release, 0)) + .getStatusCode()); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> activate(client, other, release, 0)) + .getStatusCode()); + client.ontologies().delete(ontology.getId().toString()); + assertEquals( + 404, + assertThrows(OpenMetadataException.class, () -> activate(client, ontology, release, 0)) + .getStatusCode()); + assertNull(state(other).getActiveReleaseId()); + } + + @Test + void concurrentActivationsCommitOnePointerAndOneEvent(TestNamespace ns) throws Exception { + final var ontology = createOntology(ns); + final var first = capture(ontology); + final var second = capture(ontology); + approve(ns, first); + approve(ns, second); + final var ready = new CountDownLatch(2); + try (var writers = Executors.newFixedThreadPool(2)) { + final var left = writers.submit(() -> activateTogether(ontology, first, ready)); + final var right = writers.submit(() -> activateTogether(ontology, second, ready)); + assertEquals(1, left.get(30, TimeUnit.SECONDS) + right.get(30, TimeUnit.SECONDS)); + } + assertEquals(1L, state(ontology).getActivationRevision()); + final long events = + List.of(first, second).stream() + .flatMap(release -> events(release).stream()) + .filter(event -> event.getEventType() == EventType.ACTIVATED) + .count(); + assertEquals(1, events); + } + + private static int activateTogether( + Ontology ontology, OntologyRelease release, CountDownLatch ready) + throws InterruptedException { + ready.countDown(); + assertTrue(ready.await(10, TimeUnit.SECONDS)); + try { + activate(SdkClients.adminClient(), ontology, release, 0); + return 1; + } catch (OpenMetadataException conflict) { + assertEquals(409, conflict.getStatusCode()); + return 0; + } + } + + private static void approve(TestNamespace ns, OntologyRelease release) { + final var task = submit(ns, Entity.ONTOLOGY_RELEASE, release.getFullyQualifiedName()); + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved)); + } + + private static OntologyState activate( + OpenMetadataClient client, Ontology ontology, OntologyRelease release, long expected) { + return client + .ontologies() + .activate( + ontology.getId(), + new ActivateOntologyRelease() + .withReleaseId(release.getId()) + .withExpectedActivationRevision(expected)); + } + + private static OntologyState state(Ontology ontology) { + return SdkClients.adminClient().ontologies().state(ontology.getId()); + } + + private static List events(OntologyRelease release) { + final var response = + SdkClients.adminClient() + .getHttpClient() + .execute( + HttpMethod.GET, + "/v1/ontologyReleases/" + release.getId() + "/events?limit=10", + null, + JsonNode.class); + return JsonUtils.readObjects(response.path("data").toString(), OntologyReleaseEvent.class); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyApprovalIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyApprovalIT.java new file mode 100644 index 000000000000..4ec48f09552b --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyApprovalIT.java @@ -0,0 +1,310 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.openmetadata.it.bootstrap.SharedEntities; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.TaskEntityStatus; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.models.ListParams; +import org.openmetadata.service.Entity; +import org.openmetadata.service.util.EntityUtil; + +@Execution(ExecutionMode.CONCURRENT) +@ExtendWith(TestNamespaceExtension.class) +public class IndependentOntologyApprovalIT extends BaseOntologyApprovalIT { + @Test + void consumersCannotSubmitOntologyReviews(TestNamespace ns) { + OntologyRelease target = capture(createOntology(ns)); + OpenMetadataException denied = + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.user2Client() + .tasks() + .create( + reviewRequest( + ns, Entity.ONTOLOGY_RELEASE, target.getFullyQualifiedName()))); + assertEquals(403, denied.getStatusCode()); + assertEquals( + 0, + SdkClients.adminClient() + .tasks() + .list(new ListParams().addFilter("aboutEntity", target.getFullyQualifiedName())) + .getTotal()); + } + + @Test + void aSecondSubmissionCannotReplaceAnOpenReview(TestNamespace ns) { + OntologyRelease target = capture(createOntology(ns)); + Task first = submit(ns, Entity.ONTOLOGY_RELEASE, target.getFullyQualifiedName()); + OpenMetadataException conflict = + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.adminClient() + .tasks() + .create( + reviewRequest( + ns, Entity.ONTOLOGY_RELEASE, target.getFullyQualifiedName()))); + assertEquals(409, conflict.getStatusCode()); + assertEquals( + List.of(first.getId()), + SdkClients.adminClient() + .tasks() + .list(new ListParams().addFilter("aboutEntity", target.getFullyQualifiedName())) + .getData() + .stream() + .map(Task::getId) + .toList()); + SdkClients.user1Client() + .tasks() + .resolve(first.getId().toString(), decision(TaskResolutionType.Approved)); + } + + @ParameterizedTest + @EnumSource( + value = TaskResolutionType.class, + names = {"Approved", "Rejected"}) + void decidedReleasesCannotStartAnotherReview(TaskResolutionType resolution, TestNamespace ns) { + final OntologyRelease release = capture(createOntology(ns, "decided" + resolution)); + final Task task = submit(ns, Entity.ONTOLOGY_RELEASE, release.getFullyQualifiedName()); + SdkClients.user1Client().tasks().resolve(task.getId().toString(), decision(resolution)); + final OntologyRelease decided = current(release); + assertEquals( + 409, + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.adminClient() + .tasks() + .create( + reviewRequest( + ns, Entity.ONTOLOGY_RELEASE, release.getFullyQualifiedName()))) + .getStatusCode()); + assertEquals(decided.getEntityStatus(), current(release).getEntityStatus()); + assertEquals(decided.getVersion(), current(release).getVersion()); + assertEquals( + 1, + SdkClients.adminClient() + .tasks() + .list(new ListParams().addFilter("aboutEntity", release.getFullyQualifiedName())) + .getTotal()); + } + + @Test + void concurrentSubmissionsCommitOnlyOneReviewTask(TestNamespace ns) throws Exception { + OntologyRelease ontology = capture(createOntology(ns)); + CountDownLatch start = new CountDownLatch(1); + List created = new ArrayList<>(); + try (var writers = Executors.newFixedThreadPool(2)) { + List> submissions = new ArrayList<>(); + for (int writer = 0; writer < 2; writer++) { + submissions.add( + writers.submit( + () -> { + start.await(10, TimeUnit.SECONDS); + try { + return SdkClients.adminClient() + .tasks() + .create( + reviewRequest( + ns, Entity.ONTOLOGY_RELEASE, ontology.getFullyQualifiedName())); + } catch (OpenMetadataException conflict) { + assertEquals(409, conflict.getStatusCode()); + return null; + } + })); + } + start.countDown(); + for (Future submission : submissions) { + Task task = submission.get(30, TimeUnit.SECONDS); + if (task != null) { + ns.trackRoot("task", task); + created.add(task); + } + } + } + assertEquals(1, created.size()); + assertEquals(created.getFirst().getId(), current(ontology).getReviewTask()); + assertEquals( + 1, + SdkClients.adminClient() + .tasks() + .list(new ListParams().addFilter("aboutEntity", ontology.getFullyQualifiedName())) + .getTotal()); + } + + @Test + void ownershipChangesPreserveApprovedContent(TestNamespace ns) { + Ontology root = createOntology(ns); + OntologyRelease release = capture(root); + Task task = submit(ns, Entity.ONTOLOGY_RELEASE, release.getFullyQualifiedName()); + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved)); + SdkClients.adminClient() + .ontologies() + .update( + root.getId(), + current(root).withOwners(List.of(SharedEntities.get().USER2.getEntityReference()))); + OntologyRelease approved = current(release); + assertEquals(EntityStatus.APPROVED, approved.getEntityStatus()); + assertEquals(release.getContentDigest(), approved.getContentDigest()); + assertEquals(task.getId(), approved.getReviewTask()); + } + + @Test + void existingApprovalWorkflowApprovesExactlyTheSubmittedRevision(TestNamespace ns) { + OntologyRelease ontology = capture(createOntology(ns)); + Task task = submit(ns, Entity.ONTOLOGY_RELEASE, ontology.getFullyQualifiedName()); + assertNotNull(task.getOntologyReview()); + assertEquals(ontology.getId(), task.getOntologyReview().getEntityId()); + assertEquals(EntityStatus.IN_REVIEW, current(ontology).getEntityStatus()); + + Task resolved = + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved)); + + assertEquals(TaskEntityStatus.Approved, resolved.getStatus()); + assertEquals(EntityStatus.APPROVED, current(ontology).getEntityStatus()); + assertEquals(ontology.getReleaseRevision(), current(ontology).getReleaseRevision()); + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved))); + } + + @Test + void contentAuthorCannotApproveEvenAsAdministrator(TestNamespace ns) { + OntologyRelease ontology = capture(createOntology(ns)); + Task task = submit(ns, Entity.ONTOLOGY_RELEASE, ontology.getFullyQualifiedName()); + + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.adminClient() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved))); + assertEquals(EntityStatus.IN_REVIEW, current(ontology).getEntityStatus()); + } + + @Test + void missingReviewHistoryCannotBeReplacedByTheCurrentDefinition(TestNamespace ns) { + final OntologyRelease definition = capture(createOntology(ns)); + final Task task = submit(ns, Entity.ONTOLOGY_RELEASE, definition.getFullyQualifiedName()); + final String extension = + EntityUtil.getVersionExtension( + Entity.ONTOLOGY_RELEASE, task.getOntologyReview().getEntityVersion()); + final var history = Entity.getCollectionDAO().entityExtensionDAO(); + assertNotNull(history.getExtension(definition.getId(), extension)); + history.delete(definition.getId(), extension); + + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved))); + assertNotEquals(EntityStatus.APPROVED, current(definition).getEntityStatus()); + } + + @Test + void definitionsWithoutReviewersCannotStartAnApproval(TestNamespace ns) { + Ontology ontology = + SdkClients.adminClient().ontologies().create(request(ns).withReviewers(List.of())); + ns.trackRoot("ontology", ontology); + + OntologyRelease release = capture(ontology); + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.adminClient() + .tasks() + .create( + reviewRequest(ns, Entity.ONTOLOGY_RELEASE, release.getFullyQualifiedName()))); + assertEquals(EntityStatus.DRAFT, current(release).getEntityStatus()); + } + + @Test + void cancelledReviewRequiresAFreshTaskAndCanBeResubmitted(TestNamespace ns) { + OntologyRelease ontology = capture(createOntology(ns)); + Task original = submit(ns, Entity.ONTOLOGY_RELEASE, ontology.getFullyQualifiedName()); + Task cancelled = SdkClients.adminClient().tasks().close(original.getId().toString()); + + assertEquals(TaskEntityStatus.Cancelled, cancelled.getStatus()); + assertEquals(EntityStatus.DRAFT, current(ontology).getEntityStatus()); + assertNull(current(ontology).getReviewTask()); + assertEquals(ontology.getReleaseRevision(), current(ontology).getReleaseRevision()); + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.adminClient() + .tasks() + .patch( + cancelled.getId().toString(), + JsonUtils.readTree( + "[{\"op\":\"replace\",\"path\":\"/status\",\"value\":\"Open\"}]"))); + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.user1Client() + .tasks() + .resolve(original.getId().toString(), decision(TaskResolutionType.Approved))); + + Task resubmitted = submit(ns, Entity.ONTOLOGY_RELEASE, ontology.getFullyQualifiedName()); + assertNotEquals(original.getId(), resubmitted.getId()); + assertEquals(resubmitted.getId(), current(ontology).getReviewTask()); + SdkClients.user1Client() + .tasks() + .resolve(resubmitted.getId().toString(), decision(TaskResolutionType.Approved)); + assertEquals(EntityStatus.APPROVED, current(ontology).getEntityStatus()); + } + + @Test + void workingDefinitionsHaveNoIndependentApprovalLifecycle(TestNamespace ns) { + Ontology root = createOntology(ns); + var concept = declareClass(root, "Customer", URI.create("urn:Customer")); + for (var definition : List.of(root.getEntityReference(), concept.getEntityReference())) { + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.adminClient() + .tasks() + .create( + reviewRequest( + ns, definition.getType(), definition.getFullyQualifiedName()))) + .getStatusCode()); + } + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyAuthorizationIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyAuthorizationIT.java new file mode 100644 index 000000000000..54fa6c8fb759 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyAuthorizationIT.java @@ -0,0 +1,87 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +import java.net.URI; +import java.util.Locale; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Isolated; +import org.openmetadata.it.util.OssTestServer; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.SqlQueryCounter; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.service.Entity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Isolated("Counts SQL statements while exercising release authorization over HTTP") +@ExtendWith(TestNamespaceExtension.class) +class IndependentOntologyAuthorizationIT extends BaseOntologyApprovalIT { + private static final Logger LOG = + LoggerFactory.getLogger(IndependentOntologyAuthorizationIT.class); + + @Test + void captureAndReleaseReadsLoadPermissionsPerPageInsteadOfPerDefinition(TestNamespace ns) { + assumeFalse(OssTestServer.isExternalMode(), "Counts the embedded server's SQL statements"); + final Ontology ontology = createOntology(ns); + addConcepts(ontology, 0, 4); + final Reads narrow = reads(ontology, 5); + addConcepts(ontology, 4, 40); + final Reads wide = reads(ontology, 41); + LOG.info("Release SQL reads for 4 versus 40 concepts: {} -> {}", narrow, wide); + + assertTrue( + narrow.capture() > 0 && narrow.structure() > 0, + "The SQL probe must observe both requests: " + narrow); + assertTrue( + wide.capture() <= narrow.capture() + 12, + "Definitions on the same page must not add authorization queries: " + + narrow + + " -> " + + wide); + assertTrue( + wide.structure() <= narrow.structure() + 12, + "Reading a sealed page must batch current permissions: " + narrow + " -> " + wide); + } + + private static void addConcepts(Ontology ontology, int from, int to) { + for (int index = from; index < to; index++) { + declareClass( + ontology, "Concept" + index, URI.create(ontology.getNamespace() + "Concept" + index)); + } + } + + private static Reads reads(Ontology ontology, int members) { + final OntologyRelease release; + final int captureQueries; + try (var queries = countReads()) { + release = capture(ontology); + captureQueries = queries.count(); + } + assertEquals(members, release.getMemberCount()); + try (var queries = countReads()) { + assertTrue(SdkClients.adminClient().ontologyReleases().structure(release.getId()).getValid()); + return new Reads(captureQueries, queries.count()); + } + } + + private static SqlQueryCounter countReads() { + // Each sealed member uses INSERT ... SELECT; those writes are not authorization reads. + return SqlQueryCounter.forRequests( + Entity.getJdbi(), + "select", + statement -> + !statement + .getRenderedSql() + .toLowerCase(Locale.ROOT) + .contains("insert into ontology_release_member")); + } + + private record Reads(int capture, int structure) {} +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyAxiomIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyAxiomIT.java new file mode 100644 index 000000000000..b4d79cbc129e --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyAxiomIT.java @@ -0,0 +1,331 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.util.RdfAccessFixtures; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.api.data.CaptureOntologyRelease; +import org.openmetadata.schema.api.data.CreateOntologyAxiom; +import org.openmetadata.schema.api.data.CreateOntologyProperty; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyRestrictionKind; +import org.openmetadata.schema.type.OntologyState; +import org.openmetadata.schema.type.RelationProvenance; +import org.openmetadata.schema.type.RelationshipCharacteristic; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.sdk.client.OpenMetadataClient; +import org.openmetadata.sdk.config.OpenMetadataConfig; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.models.ListParams; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.service.Entity; + +@Execution(ExecutionMode.CONCURRENT) +@ExtendWith(TestNamespaceExtension.class) +public class IndependentOntologyAxiomIT extends BaseOntologyApprovalIT { + private static final URI CUSTOMER = URI.create("https://example.org/model/Customer"); + private static final URI PARTY = URI.create("https://example.org/model/Party"); + private static final URI PROPERTY = URI.create("https://example.org/model/knows"); + + @Test + void axiomsBelongToOntologiesAndHaveNoSeparateApproval(TestNamespace ns) { + Ontology ontology = createModel(ns, "reviewedOntology"); + var service = SdkClients.adminClient().ontologyAxioms(); + OntologyAxiom axiom = service.create(axiomRequest(ontology)); + assertEquals(ontology.getId(), axiom.getOntology().getId()); + var stored = service.get(axiom.getId().toString()); + assertEquals(ontology.getId(), stored.getOntology().getId()); + Ontology other = createModel(ns, "other"); + service.create(axiomRequest(other)); + var page = service.list(new ListParams().addFilter("ontologyId", ontology.getId().toString())); + assertEquals( + List.of(axiom.getId()), page.getData().stream().map(OntologyAxiom::getId).toList()); + } + + @Test + void contentEditsAdvanceWorkingStateWithoutChangingAnApprovedRelease(TestNamespace ns) { + Ontology ontology = createModel(ns, "reviewedOntology"); + var service = SdkClients.adminClient().ontologyAxioms(); + OntologyAxiom axiom = service.create(axiomRequest(ontology)); + OntologyRelease release = capture(ontology); + var review = submit(ns, Entity.ONTOLOGY_RELEASE, release.getFullyQualifiedName()); + SdkClients.user1Client() + .tasks() + .resolve(review.getId().toString(), decision(TaskResolutionType.Approved)); + long revision = state(ontology).getWorkingRevision(); + service.update( + axiom.getId(), + axiom.withExpressions( + List.of(namedClass(URI.create("http://www.w3.org/2002/07/owl#Thing"))))); + assertTrue(state(ontology).getWorkingRevision() > revision); + assertEquals(EntityStatus.APPROVED, current(release).getEntityStatus()); + assertEquals(release.getContentDigest(), current(release).getContentDigest()); + } + + @Test + void writesCannotGrantApprovalOrMoveAnAxiom(TestNamespace ns) { + Ontology ontology = createModel(ns, "reviewedOntology"); + Ontology other = createModel(ns, "other"); + var service = SdkClients.adminClient().ontologyAxioms(); + OntologyAxiom axiom = service.create(axiomRequest(ontology)); + var statusPatch = JsonUtils.getObjectMapper().createArrayNode(); + statusPatch + .addObject() + .put("op", "replace") + .put("path", "/entityStatus") + .put("value", "Approved"); + service.patch(axiom.getId(), statusPatch); + assertNull(service.get(axiom.getId().toString()).getEntityStatus()); + var ownerPatch = JsonUtils.getObjectMapper().createArrayNode(); + ownerPatch + .addObject() + .put("op", "replace") + .put("path", "/ontology") + .set("value", JsonUtils.getObjectMapper().valueToTree(other.getEntityReference())); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> service.patch(axiom.getId(), ownerPatch)) + .getStatusCode()); + } + + @Test + void releasesFreezeAxiomsAcrossUpdatesAndDeletion(TestNamespace ns) { + Ontology ontology = createModel(ns, "reviewedOntology"); + var client = SdkClients.adminClient(); + OntologyAxiom axiom = client.ontologyAxioms().create(axiomRequest(ontology)); + OntologyRelease release = + client + .getHttpClient() + .execute( + HttpMethod.POST, + "/v1/ontologies/" + ontology.getId() + "/releases", + new CaptureOntologyRelease() + .withExpectedWorkingRevision(state(ontology).getWorkingRevision()), + OntologyRelease.class); + assertEquals(4, release.getMemberCount()); + client.ontologyAxioms().update(axiom.getId(), axiom.withDescription("Later working text")); + client.ontologyAxioms().delete(axiom.getId().toString(), Map.of("hardDelete", "true")); + OntologyReleaseMember member = + client + .getHttpClient() + .execute( + HttpMethod.GET, + "/v1/ontologyReleases/" + release.getId() + "/members/AXIOM/" + axiom.getId(), + null, + OntologyReleaseMember.class); + assertEquals("Every customer is a party", member.getPayload().path("description").asText()); + assertEquals( + ontology.getId().toString(), member.getPayload().path("ontology").path("id").asText()); + } + + @Test + void previewRequiresReadAccessToItsOwningOntology(TestNamespace ns) { + final var ontology = createModel(ns, "privateOntology"); + try (final var fixtures = new RdfAccessFixtures("preview" + ns.shortPrefix())) { + final var create = fixtures.allowRole(MetadataOperation.CREATE, Entity.ONTOLOGY_AXIOM); + final var deny = fixtures.denyRole(MetadataOperation.VIEW_ALL, Entity.ONTOLOGY); + final var client = + new OpenMetadataClient( + OpenMetadataConfig.builder() + .serverUrl(SdkClients.baseUrl()) + .accessToken(fixtures.userToken("editor", List.of(create.getId(), deny.getId()))) + .build()); + assertEquals( + 403, + assertThrows( + OpenMetadataException.class, + () -> client.ontologyAxioms().validate(axiomRequest(ontology))) + .getStatusCode()); + } + } + + @Test + void propertyChecksUseOnlyTheOwningOntology(TestNamespace ns) { + Ontology ontology = createModel(ns, "reviewedOntology"); + Ontology other = createModel(ns, "other"); + var client = SdkClients.adminClient(); + var localProperty = client.ontologyProperties().create(propertyRequest(ontology)); + client + .ontologyProperties() + .create( + propertyRequest(other) + .withCharacteristics(Set.of(RelationshipCharacteristic.TRANSITIVE))); + var restriction = + new OntologyExpression() + .withKind(OntologyExpressionKind.RESTRICTION) + .withPropertyIri(PROPERTY) + .withRestrictionKind(OntologyRestrictionKind.EXACT) + .withCardinality(1) + .withFiller(namedClass(PARTY)); + var request = axiomRequest(ontology).withExpressions(List.of(restriction)); + assertTrue(client.ontologyAxioms().validate(request).getValid()); + client + .ontologyProperties() + .update( + localProperty.getId(), + localProperty.withCharacteristics(Set.of(RelationshipCharacteristic.TRANSITIVE))); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> client.ontologyAxioms().create(request)) + .getStatusCode()); + } + + @Test + void incomingTransitiveInverseRejectsCardinalityBeforeItCanBlockCapture(TestNamespace ns) { + final var ontology = createModel(ns, "incomingInverse"); + final var client = SdkClients.adminClient(); + client.ontologyProperties().create(propertyRequest(ontology)); + client + .ontologyProperties() + .create( + propertyRequest(ontology) + .withName("knownBy") + .withIri(URI.create("https://example.org/model/knownBy")) + .withInverse(PROPERTY) + .withCharacteristics(Set.of(RelationshipCharacteristic.TRANSITIVE))); + final var request = + axiomRequest(ontology) + .withExpressions( + List.of( + new OntologyExpression() + .withKind(OntologyExpressionKind.RESTRICTION) + .withPropertyIri(PROPERTY) + .withRestrictionKind(OntologyRestrictionKind.EXACT) + .withCardinality(1) + .withFiller(namedClass(PARTY)))); + final long revision = state(ontology).getWorkingRevision(); + assertFalse(client.ontologyAxioms().validate(request).getValid()); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> client.ontologyAxioms().create(request)) + .getStatusCode()); + assertEquals(revision, state(ontology).getWorkingRevision()); + assertEquals(5, capture(ontology).getMemberCount()); + } + + @Test + void vocabularyChecksAreScopedAndPreserveExactIriSpelling(TestNamespace ns) { + Ontology ontology = createModel(ns, "reviewedOntology"); + Ontology other = createModel(ns, "other"); + var service = SdkClients.adminClient().ontologyAxioms(); + service.create(axiomRequest(other).withAxiomType(OntologyAxiomType.CLASS_ASSERTION)); + OntologyAxiom axiom = service.create(axiomRequest(ontology)); + assertEquals(CUSTOMER.toString(), axiom.getSubjectIri().toString()); + URI alternate = URI.create("HTTPS://EXAMPLE.org/model/Customer"); + OntologyAxiom individual = + service.create( + axiomRequest(ontology) + .withName("Individual") + .withAxiomType(OntologyAxiomType.CLASS_ASSERTION) + .withSubjectIri(alternate)); + assertEquals(alternate.toString(), individual.getSubjectIri().toString()); + assertNotEquals(axiom.getSubjectIri().toString(), individual.getSubjectIri().toString()); + OntologyAxiom punned = + service.create( + axiomRequest(ontology) + .withName("CustomerCategory") + .withAxiomType(OntologyAxiomType.CLASS_ASSERTION)); + assertEquals(axiom.getSubjectIri().toString(), punned.getSubjectIri().toString()); + assertNotEquals(axiom.getId(), punned.getId()); + } + + @Test + void assertionsCannotChangeTheDeclaredPropertyKind(TestNamespace ns) { + Ontology ontology = createModel(ns, "reviewedOntology"); + var client = SdkClients.adminClient(); + client + .ontologyProperties() + .create(propertyRequest(ontology).withKind(OntologyPropertyKind.DATA)); + CreateOntologyAxiom request = + axiomRequest(ontology) + .withAxiomType(OntologyAxiomType.OBJECT_PROPERTY_ASSERTION) + .withExpressions(List.of()) + .withPropertyIri(PROPERTY) + .withTargetIri(PARTY); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> client.ontologyAxioms().create(request)) + .getStatusCode()); + } + + @Test + void nativeAxiomHistoryAndRestorationRetainOwnership(TestNamespace ns) { + Ontology ontology = createModel(ns, "reviewedOntology"); + var service = SdkClients.adminClient().ontologyAxioms(); + OntologyAxiom axiom = service.create(axiomRequest(ontology)); + service.update(axiom.getId(), axiom.withDescription("Updated")); + assertEquals( + "Every customer is a party", + service.getVersion(axiom.getId().toString(), 0.1).getDescription()); + assertEquals(2, service.getVersionList(axiom.getId()).getVersions().size()); + service.delete(axiom.getId().toString()); + service.restore(axiom.getId()); + assertEquals(ontology.getId(), service.get(axiom.getId().toString()).getOntology().getId()); + } + + private static Ontology createModel(TestNamespace ns, String name) { + final var ontology = createOntology(ns, name); + declareClass(ontology, "Customer", CUSTOMER); + declareClass(ontology, "Party", PARTY); + return ontology; + } + + private static CreateOntologyAxiom axiomRequest(Ontology ontology) { + return new CreateOntologyAxiom() + .withName("CustomerIsParty") + .withDisplayName("Customer is a Party") + .withDescription("Every customer is a party") + .withOntology(ontology.getFullyQualifiedName()) + .withAxiomType(OntologyAxiomType.SUBCLASS_OF) + .withSubjectIri(CUSTOMER) + .withProvenance(RelationProvenance.MANUAL) + .withExpressions(List.of(namedClass(PARTY))); + } + + private static CreateOntologyProperty propertyRequest(Ontology ontology) { + return new CreateOntologyProperty() + .withName("knows") + .withOntology(ontology.getFullyQualifiedName()) + .withKind(OntologyPropertyKind.OBJECT) + .withIri(PROPERTY); + } + + private static OntologyExpression namedClass(URI iri) { + return new OntologyExpression().withKind(OntologyExpressionKind.NAMED_CLASS).withClassIri(iri); + } + + private static OntologyState state(Ontology ontology) { + return SdkClients.adminClient() + .getHttpClient() + .execute( + HttpMethod.GET, + "/v1/ontologies/" + ontology.getId() + "/state", + null, + OntologyState.class); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyHierarchyIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyHierarchyIT.java new file mode 100644 index 000000000000..2b243b0a1c8c --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyHierarchyIT.java @@ -0,0 +1,108 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.api.data.CreateOntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.RelationProvenance; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.models.ListParams; + +@Execution(ExecutionMode.CONCURRENT) +@ExtendWith(TestNamespaceExtension.class) +public class IndependentOntologyHierarchyIT extends BaseOntologyApprovalIT { + @Test + void hierarchyReadsAndDeletionGuardsFollowTheStoredAxioms(TestNamespace ns) { + final var ontology = createOntology(ns); + final var customer = declareClass(ontology, "Customer", URI.create("urn:Customer")); + final var party = declareClass(ontology, "Party", URI.create("urn:Party")); + final var legal = declareClass(ontology, "LegalEntity", URI.create("urn:LegalEntity")); + final var client = SdkClients.adminClient(); + final var concepts = client.ontologyConcepts(); + final var axiom = + client.ontologyAxioms().create(subclass(customer, party.getIri(), legal.getIri())); + final var revision = client.ontologies().state(ontology.getId()).getWorkingRevision(); + assertFalse(client.ontologyAxioms().validate(subclass(party, customer.getIri())).getValid()); + assertFalse( + client.ontologyAxioms().validate(subclass(customer, URI.create("urn:Unknown"))).getValid()); + assertTrue(client.ontologyAxioms().validate(subclass(customer, party.getIri())).getValid()); + assertEquals(revision, client.ontologies().state(ontology.getId()).getWorkingRevision()); + assertEquals( + List.of(legal.getIri(), party.getIri()), + concepts.get(customer.getId().toString(), "superClasses").getSuperClasses()); + assertEquals( + List.of(customer.getIri()), + concepts.get(party.getId().toString(), "subClasses").getSubClasses()); + final var page = + concepts.list( + new ListParams() + .setFields("superClasses,subClasses") + .addFilter("ontology", ontology.getFullyQualifiedName())); + assertEquals( + 2, page.getData().stream().filter(value -> !value.getSubClasses().isEmpty()).count()); + for (boolean hard : List.of(false, true)) { + assertBadRequest( + () -> + concepts.delete( + party.getId().toString(), Map.of("hardDelete", String.valueOf(hard)))); + } + assertBadRequest( + () -> + concepts.patch( + party.getId(), + JsonUtils.readTree( + """ + [{"op":"replace","path":"/conceptType","value":"SKOS_CONCEPT"}] + """))); + assertBadRequest(() -> client.ontologyAxioms().create(subclass(party, customer.getIri()))); + assertBadRequest( + () -> client.ontologyAxioms().create(subclass(customer, URI.create("urn:Unknown")))); + client.ontologyAxioms().delete(axiom.getId().toString()); + assertTrue( + concepts.get(customer.getId().toString(), "superClasses").getSuperClasses().isEmpty()); + concepts.delete(party.getId().toString(), Map.of("hardDelete", "true")); + assertBadRequest(() -> client.ontologyAxioms().restore(axiom.getId())); + } + + private static void assertBadRequest(Runnable action) { + assertEquals(400, assertThrows(OpenMetadataException.class, action::run).getStatusCode()); + } + + private static CreateOntologyAxiom subclass(OntologyConcept source, URI... targets) { + return new CreateOntologyAxiom() + .withName(source.getName() + UUID.randomUUID()) + .withDisplayName("Subclass assertion") + .withDescription("A named class hierarchy") + .withOntology(source.getOntology().getFullyQualifiedName()) + .withAxiomType(OntologyAxiomType.SUBCLASS_OF) + .withProvenance(RelationProvenance.MANUAL) + .withSubjectIri(source.getIri()) + .withExpressions( + Arrays.stream(targets) + .map( + iri -> + new OntologyExpression() + .withKind(OntologyExpressionKind.NAMED_CLASS) + .withClassIri(iri)) + .toList()); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReferenceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReferenceIT.java new file mode 100644 index 000000000000..1f210164ed41 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReferenceIT.java @@ -0,0 +1,232 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.util.RdfAccessFixtures; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.api.data.ConceptMapping; +import org.openmetadata.schema.api.data.CreateOntologyAxiom; +import org.openmetadata.schema.api.data.CreateOntologyConcept; +import org.openmetadata.schema.api.data.CreateOntologyProperty; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.RelationProvenance; +import org.openmetadata.sdk.client.OpenMetadataClient; +import org.openmetadata.sdk.config.OpenMetadataConfig; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.service.Entity; + +@Execution(ExecutionMode.CONCURRENT) +@ExtendWith(TestNamespaceExtension.class) +public class IndependentOntologyReferenceIT extends BaseOntologyApprovalIT { + @Test + void nativeWritesUpdateReferenceImpactAcrossCreatePatchDeleteAndRestore(TestNamespace ns) { + Ontology ontology = createOntology(ns); + URI target = URI.create("urn:example:Customer"); + OntologyProperty property = property(ontology, target); + var concept = + SdkClients.adminClient() + .ontologyConcepts() + .create( + new CreateOntologyConcept() + .withName("MappedCustomer") + .withConceptType(OntologyConceptType.BOTH) + .withOntology(ontology.getFullyQualifiedName()) + .withConceptMappings( + List.of( + new ConceptMapping() + .withConceptIri(target) + .withMappingType(ConceptMapping.ConceptMappingType.EXACT_MATCH)))); + declareClass(ontology, "PremiumCustomer", URI.create("urn:example:PremiumCustomer")); + OntologyAxiom axiom = + SdkClients.adminClient() + .getHttpClient() + .execute( + HttpMethod.POST, + "/v1/ontologyAxioms", + new CreateOntologyAxiom() + .withName("CustomerSubclass") + .withDisplayName("Customer subclass") + .withDescription("Reference impact test") + .withOntology(ontology.getFullyQualifiedName()) + .withAxiomType(OntologyAxiomType.SUBCLASS_OF) + .withProvenance(RelationProvenance.MANUAL) + .withSubjectIri(URI.create("urn:example:PremiumCustomer")) + .withExpressions( + List.of( + new OntologyExpression() + .withKind(OntologyExpressionKind.NAMED_CLASS) + .withClassIri(target))), + OntologyAxiom.class); + Set sources = new HashSet<>(); + Set references = new HashSet<>(); + String after = ""; + do { + JsonNode page = references(SdkClients.adminClient(), ontology, target, "&limit=1" + after); + assertEquals(4, page.path("paging").path("total").asInt()); + assertEquals(1, page.path("data").size()); + JsonNode reference = page.path("data").get(0); + assertEquals(target.toString(), reference.path("iri").asText()); + sources.add(UUID.fromString(reference.path("source").path("id").asText())); + assertTrue( + references.add( + reference.path("source").path("id").asText() + reference.path("role").asText())); + String cursor = page.path("paging").path("after").asText(""); + after = cursor.isEmpty() ? "" : "&after=" + encode(cursor); + } while (!after.isEmpty()); + assertEquals(Set.of(property.getId(), concept.getId(), axiom.getId()), sources); + URI replacement = URI.create("urn:example:Account"); + declareClass(ontology, "Account", replacement); + SdkClients.adminClient() + .ontologyProperties() + .update(property.getId(), property.withDomain(List.of(replacement))); + assertTotal(ontology, target, 3); + assertTotal(ontology, replacement, 1); + SdkClients.adminClient().ontologyConcepts().delete(concept.getId()); + assertTotal(ontology, target, 2); + SdkClients.adminClient().ontologyConcepts().restore(concept.getId()); + assertTotal(ontology, target, 3); + SdkClients.adminClient() + .getHttpClient() + .execute( + HttpMethod.DELETE, + "/v1/ontologyAxioms/" + axiom.getId() + "?hardDelete=true", + null, + Void.class); + assertTotal(ontology, target, 1); + } + + @Test + void referenceLookupsKeepExactIrisAndModuleBoundaries(TestNamespace ns) { + Ontology first = createOntology(ns, "first"); + Ontology second = createOntology(ns, "second"); + URI iri = URI.create("HTTP://example.org/Customer"); + var firstProperty = property(first, iri); + var secondProperty = property(second, iri); + assertNotEquals(firstProperty.getId(), secondProperty.getId()); + assertTotal(first, iri, 1); + assertTotal(second, iri, 1); + assertTotal(first, URI.create("http://example.org/Customer"), 0); + assertEquals( + firstProperty.getId().toString(), + references(SdkClients.adminClient(), first, iri, "") + .path("data") + .get(0) + .path("source") + .path("id") + .asText()); + } + + @Test + void impactReadsEnforceTheSourceDefinitionsPermission(TestNamespace ns) { + Ontology ontology = createOntology(ns); + URI iri = URI.create("urn:restricted"); + property(ontology, iri); + try (RdfAccessFixtures fixtures = new RdfAccessFixtures("references" + ns.shortPrefix())) { + var role = fixtures.denyRole(MetadataOperation.VIEW_ALL, Entity.ONTOLOGY_PROPERTY); + String token = fixtures.userToken("reader", List.of(role.getId())); + var denied = + new OpenMetadataClient( + OpenMetadataConfig.builder() + .serverUrl(SdkClients.baseUrl()) + .accessToken(token) + .build()); + denied.ontologies().get(ontology.getId()); + assertEquals( + 403, + assertThrows(OpenMetadataException.class, () -> references(denied, ontology, iri, "")) + .getStatusCode()); + } + } + + @Test + void impactQueriesRejectMissingRelativeIrisAndInvalidPaging(TestNamespace ns) { + Ontology ontology = createOntology(ns); + for (String query : + List.of( + "", + "?iri=relative", + "?iri=urn:target&limit=0", + "?iri=urn:target&limit=1001", + "?iri=urn:target&after=bad-cursor")) { + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.adminClient() + .getHttpClient() + .execute( + HttpMethod.GET, + "/v1/ontologies/" + ontology.getId() + "/references" + query, + null, + JsonNode.class)) + .getStatusCode()); + } + } + + private static OntologyProperty property(Ontology ontology, URI domain) { + declareClass(ontology, "Domain", domain); + return SdkClients.adminClient() + .ontologyProperties() + .create( + new CreateOntologyProperty() + .withName("property") + .withOntology(ontology.getFullyQualifiedName()) + .withKind(OntologyPropertyKind.OBJECT) + .withDomain(List.of(domain))); + } + + private static JsonNode references( + OpenMetadataClient client, Ontology ontology, URI iri, String query) { + return client + .getHttpClient() + .execute( + HttpMethod.GET, + "/v1/ontologies/" + + ontology.getId() + + "/references?iri=" + + encode(iri.toString()) + + query, + null, + JsonNode.class); + } + + private static void assertTotal(Ontology ontology, URI iri, int expected) { + assertEquals( + expected, + references(SdkClients.adminClient(), ontology, iri, "") + .path("paging") + .path("total") + .asInt()); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseDiffIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseDiffIT.java new file mode 100644 index 000000000000..7ecf9ec819cd --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseDiffIT.java @@ -0,0 +1,228 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.openmetadata.it.util.RdfAccessFixtures; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.api.data.ActivateOntologyRelease; +import org.openmetadata.schema.api.data.CreateOntologyConcept; +import org.openmetadata.schema.api.data.OntologyReleaseDiff; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyReleaseChangeType; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.sdk.client.OpenMetadataClient; +import org.openmetadata.sdk.config.OpenMetadataConfig; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.service.Entity; + +@Execution(ExecutionMode.CONCURRENT) +@ExtendWith(TestNamespaceExtension.class) +public class IndependentOntologyReleaseDiffIT extends BaseOntologyApprovalIT { + @Test + void comparisonPagesDescribeFrozenAdditionsChangesAndRemovals(TestNamespace ns) { + final var client = SdkClients.adminClient(); + final Ontology ontology = createOntology(ns); + final OntologyConcept customer = concept(ontology, "Customer"); + final OntologyConcept legacy = concept(ontology, "Legacy"); + final OntologyRelease base = capture(ontology); + client.ontologyConcepts().update(customer.getId(), customer.withDescription("Reviewed change")); + client.ontologyConcepts().delete(legacy.getId().toString(), Map.of("hardDelete", "true")); + final OntologyConcept added = concept(ontology, "NewConcept"); + final OntologyRelease release = capture(ontology); + client + .ontologyConcepts() + .update( + customer.getId(), + client + .ontologyConcepts() + .get(customer.getId()) + .withDescription("Later working change")); + client.ontologies().delete(ontology.getId().toString(), Map.of("recursive", "true")); + + final Map changes = new HashMap<>(); + String after = null; + for (int page = 0; page < 3; page++) { + final OntologyReleaseDiff result = diff(release, base, after); + assertEquals(release.getContentDigest(), result.getContentDigest()); + assertEquals(base.getContentDigest(), result.getBaseContentDigest()); + assertEquals(3, result.getPaging().getTotal()); + assertEquals(1, result.getData().size()); + final var change = result.getData().getFirst(); + final UUID id = change.getEntity().getId(); + assertNull(changes.put(id, change.getChangeType())); + assertEquals(ontology.getId(), change.getOntologyId()); + assertEquals(MemberKind.CONCEPT, change.getMemberKind()); + if (!id.equals(added.getId())) { + assertEquals(base.getId(), change.getBeforeReleaseId()); + } + if (!id.equals(legacy.getId())) { + assertEquals(release.getId(), change.getAfterReleaseId()); + } + after = result.getPaging().getAfter(); + if (page < 2) { + assertFalse(after == null || after.isBlank()); + } + } + assertNull(after); + assertEquals( + Map.of( + customer.getId(), + OntologyReleaseChangeType.CHANGED, + legacy.getId(), + OntologyReleaseChangeType.REMOVED, + added.getId(), + OntologyReleaseChangeType.ADDED), + changes); + } + + @Test + void firstReleaseHasOnlyAdditionsAndWorkflowChangesDoNotChangeMeaning(TestNamespace ns) { + final Ontology ontology = createOntology(ns); + final OntologyRelease base = capture(ontology); + final OntologyReleaseDiff first = diff(base, null, null); + assertEquals(1, first.getPaging().getTotal()); + assertEquals(OntologyReleaseChangeType.ADDED, first.getData().getFirst().getChangeType()); + assertNull(first.getData().getFirst().getBeforeReleaseId()); + + final var review = submit(ns, Entity.ONTOLOGY_RELEASE, base.getFullyQualifiedName()); + SdkClients.user1Client() + .tasks() + .resolve(review.getId().toString(), decision(TaskResolutionType.Approved)); + SdkClients.adminClient() + .ontologies() + .activate( + ontology.getId(), + new ActivateOntologyRelease() + .withReleaseId(base.getId()) + .withExpectedActivationRevision(0L)); + final OntologyRelease approved = capture(ontology); + assertEquals(base.getContentDigest(), approved.getContentDigest()); + final OntologyReleaseDiff unchanged = diff(approved, base, null); + assertEquals(0, unchanged.getPaging().getTotal()); + assertEquals(0, unchanged.getData().size()); + assertEquals(base.getId(), diff(approved, null, null).getBaseReleaseId()); + assertEquals(0, diff(approved, null, null).getPaging().getTotal()); + } + + @Test + void comparisonRequiresOneOntologyAndBindsCursorsToBothReleases(TestNamespace ns) { + final Ontology ontology = createOntology(ns); + concept(ontology, "Customer"); + final OntologyRelease release = capture(ontology); + final OntologyRelease unrelated = capture(createOntology(ns, "unrelated")); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> diff(release, unrelated, null)) + .getStatusCode()); + final String after = diff(release, null, null).getPaging().getAfter(); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> diff(release, release, after)) + .getStatusCode()); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> diff(release, null, "not-a-cursor")) + .getStatusCode()); + for (final String malformed : List.of("", "x".repeat(257))) { + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> diff(release, null, malformed)) + .getStatusCode()); + } + for (final int limit : List.of(0, 1001)) { + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.adminClient() + .ontologyReleases() + .diff(release.getId(), null, limit, null)) + .getStatusCode()); + } + assertEquals( + 404, + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.adminClient() + .ontologyReleases() + .diff(release.getId(), UUID.randomUUID(), 1, null)) + .getStatusCode()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void unreadableMembersOnEitherSideRejectTheWholeComparison(boolean removal, TestNamespace ns) { + final Ontology ontology = createOntology(ns, "permissions" + removal); + final OntologyConcept member = concept(ontology, "Restricted"); + final OntologyRelease populated = capture(ontology); + SdkClients.adminClient() + .ontologyConcepts() + .delete(member.getId().toString(), Map.of("hardDelete", "true")); + final OntologyRelease empty = capture(ontology); + try (var fixtures = new RdfAccessFixtures("diff" + ns.shortPrefix() + removal)) { + final var role = fixtures.denyRole(MetadataOperation.VIEW_ALL, Entity.ONTOLOGY_CONCEPT); + final var denied = + new OpenMetadataClient( + OpenMetadataConfig.builder() + .serverUrl(SdkClients.baseUrl()) + .accessToken(fixtures.userToken("reader", List.of(role.getId()))) + .build()); + assertEquals(empty.getId(), denied.ontologyReleases().get(empty.getId()).getId()); + assertEquals(populated.getId(), denied.ontologyReleases().get(populated.getId()).getId()); + assertEquals( + 1, denied.ontologyReleases().diff(empty.getId(), null, 1, null).getPaging().getTotal()); + assertEquals( + 403, + assertThrows( + OpenMetadataException.class, + () -> + denied + .ontologyReleases() + .diff( + removal ? empty.getId() : populated.getId(), + removal ? populated.getId() : empty.getId(), + 1, + null)) + .getStatusCode()); + } + } + + private static OntologyConcept concept(Ontology ontology, String name) { + return SdkClients.adminClient() + .ontologyConcepts() + .create( + new CreateOntologyConcept() + .withName(name) + .withOntology(ontology.getFullyQualifiedName()) + .withConceptType(OntologyConceptType.OWL_CLASS)); + } + + private static OntologyReleaseDiff diff( + OntologyRelease release, OntologyRelease base, String after) { + return SdkClients.adminClient() + .ontologyReleases() + .diff(release.getId(), base == null ? null : base.getId(), 1, after); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseExportIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseExportIT.java new file mode 100644 index 000000000000..ab27262429de --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseExportIT.java @@ -0,0 +1,320 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.RDFList; +import org.apache.jena.riot.RDFParser; +import org.apache.jena.vocabulary.OWL2; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.openmetadata.it.util.RdfAccessFixtures; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.api.data.CreateOntologyAxiom; +import org.openmetadata.schema.api.data.CreateOntologyConcept; +import org.openmetadata.schema.api.data.CreateOntologyProperty; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyLiteral; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.RelationProvenance; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.sdk.client.OpenMetadataClient; +import org.openmetadata.sdk.config.OpenMetadataConfig; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.network.RequestOptions; +import org.openmetadata.service.rdf.RdfSerializationFormat; + +@Execution(ExecutionMode.CONCURRENT) +@ExtendWith(TestNamespaceExtension.class) +public class IndependentOntologyReleaseExportIT extends BaseOntologyApprovalIT { + private static final URI CUSTOMER = URI.create("https://example.org/model/Customer"); + private static final URI PARTY = URI.create("https://example.org/model/Party"); + private static final URI KNOWS = URI.create("https://example.org/model/knows"); + + @Test + void structuralGateProtectsDeclarationsAndReportsTheFrozenModelAfterDeletion(TestNamespace ns) { + var client = SdkClients.adminClient(); + Ontology ontology = createOntology(ns, "structure"); + declareClass(ontology, "Customer", CUSTOMER); + declareClass(ontology, "Party", PARTY); + var declaration = client.ontologyProperties().create(property(ontology)); + var assertion = + client + .ontologyAxioms() + .create( + new CreateOntologyAxiom() + .withName("customer-knows-party") + .withDisplayName("Customer knows party") + .withDescription("A captured object-property assertion") + .withProvenance(RelationProvenance.MANUAL) + .withOntology(ontology.getFullyQualifiedName()) + .withSubjectIri(CUSTOMER) + .withAxiomType(OntologyAxiomType.OBJECT_PROPERTY_ASSERTION) + .withPropertyIri(KNOWS) + .withTargetIri(PARTY)); + OntologyRelease valid = capture(ontology); + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, + () -> + client + .ontologyProperties() + .patch( + declaration.getId(), + JsonUtils.readTree( + """ + [{"op":"replace","path":"/kind","value":"DATA"}, + {"op":"replace","path":"/range","value":[]}] + """))) + .getStatusCode()); + client.ontologyAxioms().delete(assertion.getId().toString(), Map.of("hardDelete", "true")); + client + .ontologyProperties() + .delete(declaration.getId().toString(), Map.of("hardDelete", "true")); + + var validReport = client.ontologyReleases().structure(valid.getId()); + assertTrue(validReport.getValid()); + assertEquals(valid.getContentDigest(), validReport.getContentDigest()); + assertEquals(0, validReport.getViolationCount()); + assertEquals( + valid.getEntityStatus(), client.ontologyReleases().get(valid.getId()).getEntityStatus()); + } + + @ParameterizedTest + @EnumSource(RdfSerializationFormat.class) + void exportsFrozenDefinitionsWithoutAnRdfServer(RdfSerializationFormat format, TestNamespace ns) { + var client = SdkClients.adminClient(); + Ontology ontology = createOntology(ns, "export" + format.name()); + var concept = + client + .ontologyConcepts() + .create( + new CreateOntologyConcept() + .withName("Customer") + .withDisplayName("Captured label") + .withIri(CUSTOMER) + .withConceptType(OntologyConceptType.OWL_CLASS) + .withOntology(ontology.getFullyQualifiedName())); + declareClass(ontology, "Party", PARTY); + var objectProperty = client.ontologyProperties().create(property(ontology)); + var dataProperty = + client + .ontologyProperties() + .create( + new CreateOntologyProperty() + .withName("customerCode") + .withOntology(ontology.getFullyQualifiedName()) + .withKind(OntologyPropertyKind.DATA) + .withIri(URI.create("urn:example:customerCode")) + .withEnumValues( + List.of( + new OntologyLiteral().withValue("Gold"), + new OntologyLiteral().withValue("Silver"))) + .withUnit("kg") + .withIsIdentifier(true)); + var subclass = client.ontologyAxioms().create(axiom(ontology)); + OntologyRelease release = capture(ontology); + client.ontologyConcepts().update(concept.getId(), concept.withDisplayName("Later label")); + client.ontologyAxioms().delete(subclass.getId().toString(), Map.of("hardDelete", "true")); + client + .ontologyProperties() + .delete(objectProperty.getId().toString(), Map.of("hardDelete", "true")); + client.ontologyConcepts().delete(concept.getId().toString(), Map.of("hardDelete", "true")); + client + .ontologyProperties() + .update( + dataProperty.getId(), + dataProperty + .withEnumValues(List.of(new OntologyLiteral().withValue("Later value"))) + .withUnit("g")); + client + .ontologyProperties() + .delete(dataProperty.getId().toString(), Map.of("hardDelete", "true")); + + String exported = export(client, release.getId(), format.externalName()); + Model model = ModelFactory.createDefaultModel(); + try { + RDFParser.fromString(exported, format.rdfFormat().getLang()).parse(model); + assertTrue( + model.contains(model.createResource(CUSTOMER.toString()), RDFS.label, "Captured label")); + assertFalse(model.contains(null, RDFS.label, "Later label")); + assertTrue( + model.contains( + model.createResource(CUSTOMER.toString()), + RDFS.subClassOf, + model.createResource(PARTY.toString()))); + assertTrue( + model.contains(model.createResource(KNOWS.toString()), RDF.type, OWL2.ObjectProperty)); + final var values = + model + .getResource("urn:example:customerCode") + .getProperty(RDFS.range) + .getResource() + .getProperty(OWL2.equivalentClass) + .getResource() + .getProperty(OWL2.oneOf) + .getResource() + .as(RDFList.class) + .asJavaList(); + assertEquals( + List.of("Gold", "Silver"), + values.stream().map(value -> value.asLiteral().getString()).toList()); + assertTrue( + model.contains( + model.getResource("urn:example:customerCode"), + model.createProperty("https://open-metadata.org/ontology/unitAnnotation"), + "kg")); + assertFalse( + model.contains( + model.getResource("urn:example:customerCode"), RDF.type, OWL2.FunctionalProperty)); + assertTrue( + model.contains( + model.createResource(ontology.getIri().toString()), + OWL2.versionIRI, + model.createResource(release.getVersionIri().toString()))); + } finally { + model.close(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"ontologyConcept", "ontologyProperty", "ontologyAxiom"}) + void exportRequiresPermissionForEveryFrozenMember(String deniedType, TestNamespace ns) { + var client = SdkClients.adminClient(); + Ontology ontology = createOntology(ns, "denied" + deniedType); + var concept = + client + .ontologyConcepts() + .create( + new CreateOntologyConcept() + .withName("Customer") + .withIri(CUSTOMER) + .withConceptType(OntologyConceptType.OWL_CLASS) + .withOntology(ontology.getFullyQualifiedName())); + declareClass(ontology, "Party", PARTY); + var property = client.ontologyProperties().create(property(ontology)); + var axiom = client.ontologyAxioms().create(axiom(ontology)); + OntologyRelease release = capture(ontology); + try (RdfAccessFixtures fixtures = + new RdfAccessFixtures("export" + deniedType + ns.shortPrefix())) { + var role = fixtures.denyRole(MetadataOperation.VIEW_ALL, deniedType); + String token = fixtures.userToken("reader", List.of(role.getId())); + var denied = + new OpenMetadataClient( + OpenMetadataConfig.builder() + .serverUrl(SdkClients.baseUrl()) + .accessToken(token) + .build()); + assertNotNull( + denied + .getHttpClient() + .execute( + HttpMethod.GET, + "/v1/ontologyReleases/" + release.getId(), + null, + OntologyRelease.class)); + assertEquals( + 403, + assertThrows(OpenMetadataException.class, () -> export(denied, release.getId(), "turtle")) + .getStatusCode()); + assertEquals( + 403, + assertThrows( + OpenMetadataException.class, + () -> denied.ontologyReleases().structure(release.getId())) + .getStatusCode()); + client.ontologyAxioms().delete(axiom.getId().toString(), Map.of("hardDelete", "true")); + client.ontologyProperties().delete(property.getId().toString(), Map.of("hardDelete", "true")); + client.ontologyConcepts().delete(concept.getId().toString(), Map.of("hardDelete", "true")); + assertEquals( + 403, + assertThrows(OpenMetadataException.class, () -> export(denied, release.getId(), "turtle")) + .getStatusCode()); + assertEquals( + 403, + assertThrows( + OpenMetadataException.class, + () -> denied.ontologyReleases().structure(release.getId())) + .getStatusCode()); + } + } + + @Test + void rejectsUnknownFormatsAndMissingReleases(TestNamespace ns) { + OntologyRelease release = capture(createOntology(ns)); + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, + () -> export(SdkClients.adminClient(), release.getId(), "unknown")) + .getStatusCode()); + assertEquals( + 404, + assertThrows( + OpenMetadataException.class, + () -> export(SdkClients.adminClient(), UUID.randomUUID(), "turtle")) + .getStatusCode()); + } + + private static CreateOntologyProperty property(Ontology ontology) { + return new CreateOntologyProperty() + .withName("knows") + .withIri(KNOWS) + .withKind(OntologyPropertyKind.OBJECT) + .withDomain(List.of(CUSTOMER)) + .withRange(List.of(PARTY)) + .withOntology(ontology.getFullyQualifiedName()); + } + + private static CreateOntologyAxiom axiom(Ontology ontology) { + return new CreateOntologyAxiom() + .withName("customer-is-party") + .withDisplayName("Customer is a party") + .withDescription("Every customer is a party") + .withOntology(ontology.getFullyQualifiedName()) + .withAxiomType(OntologyAxiomType.SUBCLASS_OF) + .withSubjectIri(CUSTOMER) + .withProvenance(RelationProvenance.MANUAL) + .withExpressions( + List.of( + new OntologyExpression() + .withKind(OntologyExpressionKind.NAMED_CLASS) + .withClassIri(PARTY))); + } + + private static String export(OpenMetadataClient client, UUID releaseId, String format) { + return client + .getHttpClient() + .executeForString( + HttpMethod.GET, + "/v1/ontologyReleases/" + releaseId + "/export?format=" + format, + null, + RequestOptions.builder().header("Accept", "*/*").build()); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseIT.java new file mode 100644 index 000000000000..42829f2897db --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseIT.java @@ -0,0 +1,466 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.bootstrap.SharedEntities; +import org.openmetadata.it.util.RdfAccessFixtures; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.api.data.CaptureOntologyRelease; +import org.openmetadata.schema.api.data.CreateOntologyConcept; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyReleaseEvent; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyState; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.sdk.client.OpenMetadataClient; +import org.openmetadata.sdk.config.OpenMetadataConfig; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.service.Entity; +import org.openmetadata.service.ontology.OntologyReview; + +@Execution(ExecutionMode.CONCURRENT) +@ExtendWith(TestNamespaceExtension.class) +public class IndependentOntologyReleaseIT extends BaseOntologyApprovalIT { + + @Test + void releaseMembershipCannotBypassConceptReadPermissions(TestNamespace ns) { + Ontology ontology = createOntology(ns); + OntologyConcept concept = + SdkClients.adminClient() + .ontologyConcepts() + .create( + new CreateOntologyConcept() + .withName("RestrictedConcept") + .withConceptType(OntologyConceptType.OWL_CLASS) + .withOntology(ontology.getFullyQualifiedName())); + OntologyRelease release = capture(ontology, state(ontology).getWorkingRevision()); + try (RdfAccessFixtures fixtures = new RdfAccessFixtures("release" + ns.shortPrefix())) { + var role = fixtures.denyRole(MetadataOperation.VIEW_ALL, Entity.ONTOLOGY_CONCEPT); + var editor = fixtures.allowRole(MetadataOperation.EDIT_ALL, Entity.ONTOLOGY); + String token = fixtures.userToken("reader", List.of(role.getId(), editor.getId())); + OpenMetadataClient denied = + new OpenMetadataClient( + OpenMetadataConfig.builder() + .serverUrl(SdkClients.baseUrl()) + .accessToken(token) + .build()); + assertEquals( + 403, + assertThrows( + OpenMetadataException.class, + () -> denied.ontologyConcepts().get(concept.getId().toString())) + .getStatusCode()); + assertNotNull( + denied + .getHttpClient() + .execute( + HttpMethod.GET, + "/v1/ontologyReleases/" + release.getId(), + null, + OntologyRelease.class)); + assertMemberReadDenied(denied, release, concept); + assertEquals( + 403, + assertThrows( + OpenMetadataException.class, + () -> + denied + .getHttpClient() + .execute( + HttpMethod.POST, + "/v1/ontologies/" + ontology.getId() + "/releases", + new CaptureOntologyRelease() + .withExpectedWorkingRevision( + state(ontology).getWorkingRevision()), + OntologyRelease.class)) + .getStatusCode()); + assertEquals(1L, state(ontology).getLastReleaseRevision()); + SdkClients.adminClient() + .ontologyConcepts() + .delete(concept.getId().toString(), Map.of("hardDelete", "true")); + assertMemberReadDenied(denied, release, concept); + } + } + + @Test + void concurrentCapturesAllocateDistinctRevisionsForTheSameContent(TestNamespace ns) + throws Exception { + Ontology ontology = createOntology(ns); + long revision = state(ontology).getWorkingRevision(); + Callable capture = () -> capture(ontology, revision); + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(capture); + var second = executor.submit(capture); + OntologyRelease one = first.get(30, java.util.concurrent.TimeUnit.SECONDS); + OntologyRelease two = second.get(30, java.util.concurrent.TimeUnit.SECONDS); + assertEquals(Set.of(1L, 2L), Set.of(one.getReleaseRevision(), two.getReleaseRevision())); + assertNotEquals(one.getId(), two.getId()); + assertEquals(one.getContentDigest(), two.getContentDigest()); + } + assertEquals(2L, state(ontology).getLastReleaseRevision()); + assertEquals(revision, state(ontology).getWorkingRevision()); + } + + @Test + void capturedReleasesImmediatelyAppearInTheirOntologyList(TestNamespace ns) { + Ontology ontology = createOntology(ns); + String path = "/v1/ontologyReleases?ontologyId=" + ontology.getId(); + JsonNode before = + SdkClients.adminClient() + .getHttpClient() + .execute(HttpMethod.GET, path, null, JsonNode.class); + assertEquals(0, before.path("paging").path("total").asInt()); + OntologyRelease release = capture(ontology, state(ontology).getWorkingRevision()); + JsonNode after = + SdkClients.adminClient() + .getHttpClient() + .execute(HttpMethod.GET, path, null, JsonNode.class); + assertEquals(1, after.path("paging").path("total").asInt()); + assertEquals(release.getId().toString(), after.path("data").get(0).path("id").asText()); + } + + @Test + void capturesRequireOntologyEditPermission(TestNamespace ns) { + Ontology ontology = createOntology(ns); + long revision = state(ontology).getWorkingRevision(); + assertEquals( + 403, + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.user2Client() + .getHttpClient() + .execute( + HttpMethod.POST, + "/v1/ontologies/" + ontology.getId() + "/releases", + new CaptureOntologyRelease().withExpectedWorkingRevision(revision), + OntologyRelease.class)) + .getStatusCode()); + assertEquals(0L, state(ontology).getLastReleaseRevision()); + } + + @Test + void memberPagingStaysWithinTheRequestedRelease(TestNamespace ns) { + Ontology ontology = createOntology(ns); + for (String name : new String[] {"Customer", "Account"}) { + SdkClients.adminClient() + .ontologyConcepts() + .create( + new CreateOntologyConcept() + .withName(name) + .withConceptType(OntologyConceptType.OWL_CLASS) + .withOntology(ontology.getFullyQualifiedName())); + } + OntologyRelease release = capture(ontology, state(ontology).getWorkingRevision()); + String path = "/v1/ontologyReleases/" + release.getId() + "/members/CONCEPT?limit=1"; + JsonNode first = + SdkClients.adminClient() + .getHttpClient() + .execute(HttpMethod.GET, path, null, JsonNode.class); + assertEquals(1, first.path("data").size()); + assertEquals(2, first.path("paging").path("total").asInt()); + String after = first.path("paging").path("after").asText(); + assertTrue(!after.isBlank()); + SdkClients.adminClient() + .ontologyConcepts() + .create( + new CreateOntologyConcept() + .withName("LaterDraft") + .withConceptType(OntologyConceptType.OWL_CLASS) + .withOntology(ontology.getFullyQualifiedName())); + capture(ontology, state(ontology).getWorkingRevision()); + JsonNode second = + SdkClients.adminClient() + .getHttpClient() + .execute(HttpMethod.GET, path + "&after=" + after, null, JsonNode.class); + assertEquals(1, second.path("data").size()); + assertEquals(release.getId().toString(), second.path("data").get(0).path("releaseId").asText()); + assertNotEquals( + first.path("data").get(0).path("memberId"), second.path("data").get(0).path("memberId")); + assertTrue( + second.path("paging").path("after").isMissingNode() + || second.path("paging").path("after").isNull()); + } + + @Test + void capturedMembersSurviveWorkingEditsAndDeletion(TestNamespace ns) { + Ontology ontology = createOntology(ns); + OntologyConcept concept = + SdkClients.adminClient() + .ontologyConcepts() + .create( + new CreateOntologyConcept() + .withName("Customer") + .withConceptType(OntologyConceptType.OWL_CLASS) + .withOntology(ontology.getFullyQualifiedName()) + .withDescription("Captured definition")); + OntologyState before = state(ontology); + OntologyRelease release = capture(ontology, before.getWorkingRevision()); + assertEquals(EntityStatus.DRAFT, release.getEntityStatus()); + assertEquals(SharedEntities.get().USER1.getId(), release.getReviewers().getFirst().getId()); + assertEquals(2, release.getMemberCount()); + assertEquals(before.getWorkingRevision(), release.getSourceWorkingRevision()); + assertNotNull(release.getContentDigest()); + assertNotNull(release.getSealedAt()); + assertNull(state(ontology).getActiveReleaseId()); + OntologyReleaseMember frozen = member(release, concept.getId()); + assertEquals(concept.getVersion(), frozen.getSourceVersion()); + for (String field : List.of("version", "owners", "reviewers", "href", "changeDescription")) { + assertTrue(frozen.getPayload().path(field).isMissingNode(), field); + } + assertEquals("Captured definition", frozen.getPayload().path("description").asText()); + SdkClients.adminClient() + .ontologyConcepts() + .update(concept.getId(), concept.withDescription("Working edit")); + assertTrue(state(ontology).getWorkingRevision() > before.getWorkingRevision()); + assertEquals(frozen, member(release, concept.getId())); + SdkClients.adminClient() + .ontologyConcepts() + .delete(concept.getId().toString(), Map.of("hardDelete", "true")); + assertEquals(frozen, member(release, concept.getId())); + OntologyRelease later = capture(ontology, state(ontology).getWorkingRevision()); + assertEquals(2L, later.getReleaseRevision()); + assertEquals(1, later.getMemberCount()); + assertNotEquals(release.getContentDigest(), later.getContentDigest()); + } + + @Test + void staleCaptureLeavesTheReleaseCounterUnchanged(TestNamespace ns) { + Ontology ontology = createOntology(ns); + OntologyState before = state(ontology); + SdkClients.adminClient() + .ontologies() + .update(ontology.getId(), ontology.withDescription("New working revision")); + assertEquals( + 412, + assertThrows( + OpenMetadataException.class, () -> capture(ontology, before.getWorkingRevision())) + .getStatusCode()); + assertEquals(0L, state(ontology).getLastReleaseRevision()); + assertNull(state(ontology).getActiveReleaseId()); + } + + @Test + void releasesUseExistingApprovalTasksAndIgnoreLaterWorkingEdits(TestNamespace ns) { + Ontology ontology = createOntology(ns); + OntologyRelease release = capture(ontology, state(ontology).getWorkingRevision()); + Task task = submit(ns, "ontologyRelease", release.getFullyQualifiedName()); + SdkClients.adminClient() + .ontologies() + .update(ontology.getId(), ontology.withDescription("Next release draft")); + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved)); + OntologyRelease approved = get(release.getId()); + assertEquals(EntityStatus.APPROVED, approved.getEntityStatus()); + assertEquals(release.getContentDigest(), approved.getContentDigest()); + final OntologyRelease reviewed = + SdkClients.user1Client() + .ontologyReleases() + .getVersion(release.getId(), task.getOntologyReview().getEntityVersion()); + assertEquals(release.getVersion(), reviewed.getVersion()); + assertEquals(EntityStatus.DRAFT, reviewed.getEntityStatus()); + assertEquals(release.getContentDigest(), reviewed.getContentDigest()); + assertEquals(task.getOntologyReview().getContentDigest(), OntologyReview.digest(reviewed)); + assertNull(state(ontology).getActiveReleaseId()); + JsonNode firstPage = events(release, "?limit=1"); + assertEquals(2, firstPage.path("paging").path("total").asInt()); + assertEquals("CAPTURED", firstPage.path("data").get(0).path("eventType").asText()); + String cursor = firstPage.path("paging").path("after").asText(); + JsonNode lastPage = events(release, "?limit=1&after=" + cursor); + OntologyReleaseEvent decision = + JsonUtils.getObjectMapper() + .convertValue(lastPage.path("data").get(0), OntologyReleaseEvent.class); + assertEquals(OntologyReleaseEvent.EventType.APPROVED, decision.getEventType()); + assertEquals(task.getId(), decision.getTaskId()); + assertEquals(task.getWorkflowDefinitionId(), decision.getWorkflowDefinitionId()); + assertEquals(task.getOntologyReview(), decision.getReviewTarget()); + assertEquals(List.of(SharedEntities.get().USER1.getName()), decision.getVoters()); + assertEquals(release.getContentDigest(), decision.getContentDigest()); + assertEquals(TaskResolutionType.Approved, decision.getResolution().getType()); + OntologyRelease other = capture(ontology, state(ontology).getWorkingRevision()); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> events(other, "?after=" + cursor)) + .getStatusCode()); + } + + @Test + void releaseHeaderHistoryRequiresReleaseReadPermission(TestNamespace ns) { + Ontology ontology = createOntology(ns); + OntologyRelease release = capture(ontology, state(ontology).getWorkingRevision()); + try (RdfAccessFixtures fixtures = new RdfAccessFixtures("history" + ns.shortPrefix())) { + var role = fixtures.denyRole(MetadataOperation.VIEW_ALL, Entity.ONTOLOGY_RELEASE); + var denied = + new OpenMetadataClient( + OpenMetadataConfig.builder() + .serverUrl(SdkClients.baseUrl()) + .accessToken(fixtures.userToken("reader", List.of(role.getId()))) + .build()); + assertEquals( + 403, + assertThrows( + OpenMetadataException.class, + () -> denied.ontologyReleases().getVersion(release.getId(), release.getVersion())) + .getStatusCode()); + } + } + + @Test + void rejectedReleaseKeepsItsSealedContentAndWorkflowEvidence(TestNamespace ns) { + Ontology ontology = createOntology(ns); + OntologyRelease release = capture(ontology, state(ontology).getWorkingRevision()); + Task task = submit(ns, "ontologyRelease", release.getFullyQualifiedName()); + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Rejected)); + assertEquals(EntityStatus.REJECTED, get(release.getId()).getEntityStatus()); + assertEquals(release.getContentDigest(), get(release.getId()).getContentDigest()); + JsonNode events = events(release, ""); + assertEquals(2, events.path("data").size()); + assertEquals("REJECTED", events.path("data").get(1).path("eventType").asText()); + assertEquals(task.getId().toString(), events.path("data").get(1).path("taskId").asText()); + assertNull(state(ontology).getActiveReleaseId()); + } + + @Test + void changingReviewersInvalidatesPendingReleaseApproval(TestNamespace ns) { + Ontology ontology = createOntology(ns); + OntologyRelease release = capture(ontology, state(ontology).getWorkingRevision()); + Task task = submit(ns, "ontologyRelease", release.getFullyQualifiedName()); + SdkClients.adminClient() + .ontologies() + .update( + ontology.getId(), + ontology.withReviewers(List.of(SharedEntities.get().USER2.getEntityReference()))); + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved))); + assertNotEquals(EntityStatus.APPROVED, get(release.getId()).getEntityStatus()); + assertEquals(1, events(release, "").path("data").size()); + assertNull(state(ontology).getActiveReleaseId()); + } + + @Test + void sealedContentCannotBePatchedOrApprovedDirectly(TestNamespace ns) { + Ontology ontology = createOntology(ns); + OntologyRelease release = capture(ontology, state(ontology).getWorkingRevision()); + for (String field : new String[] {"contentDigest", "entityStatus"}) { + var patch = JsonUtils.getObjectMapper().createArrayNode(); + patch + .addObject() + .put("op", "replace") + .put("path", "/" + field) + .put("value", field.equals("entityStatus") ? "Approved" : "0".repeat(64)); + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.adminClient() + .getHttpClient() + .execute( + HttpMethod.PATCH, + "/v1/ontologyReleases/" + release.getId(), + patch, + OntologyRelease.class)); + } + assertEquals(release.getContentDigest(), get(release.getId()).getContentDigest()); + assertEquals(EntityStatus.DRAFT, get(release.getId()).getEntityStatus()); + } + + private static OntologyState state(Ontology ontology) { + return SdkClients.adminClient() + .getHttpClient() + .execute( + HttpMethod.GET, + "/v1/ontologies/" + ontology.getId() + "/state", + null, + OntologyState.class); + } + + private static OntologyRelease capture(Ontology ontology, long revision) { + return SdkClients.adminClient() + .getHttpClient() + .execute( + HttpMethod.POST, + "/v1/ontologies/" + ontology.getId() + "/releases", + new CaptureOntologyRelease().withExpectedWorkingRevision(revision), + OntologyRelease.class); + } + + private static OntologyRelease get(UUID id) { + return SdkClients.adminClient() + .getHttpClient() + .execute(HttpMethod.GET, "/v1/ontologyReleases/" + id, null, OntologyRelease.class); + } + + private static JsonNode events(OntologyRelease release, String query) { + return SdkClients.adminClient() + .getHttpClient() + .execute( + HttpMethod.GET, + "/v1/ontologyReleases/" + release.getId() + "/events" + query, + null, + JsonNode.class); + } + + private static void assertMemberReadDenied( + OpenMetadataClient denied, OntologyRelease release, OntologyConcept concept) { + for (String suffix : List.of("", "/" + concept.getId())) { + assertEquals( + 403, + assertThrows( + OpenMetadataException.class, + () -> + denied + .getHttpClient() + .execute( + HttpMethod.GET, + "/v1/ontologyReleases/" + + release.getId() + + "/members/CONCEPT" + + suffix, + null, + JsonNode.class)) + .getStatusCode()); + } + } + + private static OntologyReleaseMember member(OntologyRelease release, UUID conceptId) { + return SdkClients.adminClient() + .getHttpClient() + .execute( + HttpMethod.GET, + "/v1/ontologyReleases/" + release.getId() + "/members/CONCEPT/" + conceptId, + null, + OntologyReleaseMember.class); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyResourceIT.java new file mode 100644 index 000000000000..1c924f39a091 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyResourceIT.java @@ -0,0 +1,126 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.schema.api.data.CreateOntology; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.type.OntologyPrefix; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.models.ListParams; +import org.openmetadata.sdk.models.ListResponse; +import org.openmetadata.sdk.services.ontology.OntologyService; + +@Execution(ExecutionMode.CONCURRENT) +public class IndependentOntologyResourceIT extends BaseOntologyIT { + @Test + void prefixesRequireUniqueNamesAndExactNamespaces(TestNamespace ns) { + OntologyPrefix first = + new OntologyPrefix().withPrefix("ex").withNamespace(URI.create("HTTPS://EXAMPLE.org/客户#")); + OntologyPrefix second = + new OntologyPrefix().withPrefix("EX").withNamespace(URI.create("https://example.org/客户#")); + CreateOntology request = createMinimalRequest(ns).withPrefixes(List.of(first, second)); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> createEntity(request)).getStatusCode()); + second.withPrefix("other").withNamespace(first.getNamespace()); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> createEntity(request)).getStatusCode()); + second.withNamespace(URI.create("relative")); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> createEntity(request)).getStatusCode()); + second.withNamespace(URI.create("https://example.org/客户#")); + Ontology created = createEntity(request); + assertEquals( + List.of(first.getNamespace().toString(), second.getNamespace().toString()), + getEntity(created.getId().toString()).getPrefixes().stream() + .map(prefix -> prefix.getNamespace().toString()) + .toList()); + } + + @Override + protected OntologyService entityService() { + return SdkClients.adminClient().ontologies(); + } + + @Override + protected CreateOntology createMinimalRequest(TestNamespace ns) { + return createRequest(ns.prefix("ontology"), ns); + } + + @Override + protected CreateOntology createRequest(String name, TestNamespace ns) { + return new CreateOntology() + .withName(name) + .withDescription("Ontology definition") + .withIri(definitionIri(name, ns)) + .withNamespace(URI.create("https://example.org/ontology/")); + } + + @Override + protected Ontology createEntity(CreateOntology request) { + return SdkClients.adminClient().ontologies().create(request); + } + + @Override + protected Ontology getEntity(String id) { + return SdkClients.adminClient().ontologies().get(id); + } + + @Override + protected Ontology getEntityByName(String name) { + return SdkClients.adminClient().ontologies().getByName(name); + } + + @Override + protected ListResponse listEntities(ListParams params) { + return SdkClients.adminClient().ontologies().list(params); + } + + @Override + protected Ontology patchEntity(String id, Ontology entity) { + return SdkClients.adminClient().ontologies().update(id, entity); + } + + @Override + protected void deleteEntity(String id) { + SdkClients.adminClient().ontologies().delete(id); + } + + @Override + protected void restoreEntity(String id) { + SdkClients.adminClient().ontologies().restore(id); + } + + @Override + protected void hardDeleteEntity(String id) { + SdkClients.adminClient() + .ontologies() + .delete(id, Map.of("hardDelete", "true", "recursive", "true")); + } + + @Override + protected String getEntityType() { + return "ontology"; + } + + @Override + protected String getResourcePath() { + return "/v1/ontologies/"; + } + + @Override + protected void validateCreatedEntity(Ontology entity, CreateOntology request) { + assertEquals(request.getIri(), entity.getIri()); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyRetentionIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyRetentionIT.java new file mode 100644 index 000000000000..4fc4d463876d --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyRetentionIT.java @@ -0,0 +1,175 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.api.data.CreateOntologyConcept; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.TaskEntityStatus; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.network.RequestOptions; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.governance.workflows.OntologyReviewReconciler; + +@Execution(ExecutionMode.CONCURRENT) +@ExtendWith(TestNamespaceExtension.class) +public class IndependentOntologyRetentionIT extends BaseOntologyApprovalIT { + @Test + void archivingPreservesReleaseReadsAndDeletedMemberInheritance(TestNamespace ns) { + var client = SdkClients.adminClient(); + Ontology ontology = createOntology(ns); + OntologyConcept concept = concept(ontology); + var release = capture(ontology); + client.ontologies().delete(ontology.getId().toString(), Map.of("recursive", "true")); + + assertEquals( + release.getContentDigest(), + client.ontologyReleases().get(release.getId()).getContentDigest()); + assertTrue(client.ontologyReleases().structure(release.getId()).getValid()); + var archived = client.ontologyConcepts().get(concept.getId().toString(), "owners", "deleted"); + assertTrue(archived.getDeleted()); + var member = + client + .getHttpClient() + .execute( + HttpMethod.GET, + "/v1/ontologyReleases/" + release.getId() + "/members/CONCEPT/" + concept.getId(), + null, + OntologyReleaseMember.class); + assertEquals(concept.getId().toString(), member.getPayload().path("id").asText()); + var exported = + client + .getHttpClient() + .executeForString( + HttpMethod.GET, + "/v1/ontologyReleases/" + release.getId() + "/export", + null, + RequestOptions.builder().header("Accept", "text/turtle").build()); + assertTrue(exported.contains("Customer")); + + client.ontologies().restore(ontology.getId()); + assertFalse(client.ontologyConcepts().get(concept.getId().toString()).getDeleted()); + assertEquals( + release.getContentDigest(), + client.ontologyReleases().get(release.getId()).getContentDigest()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void retainedReleasesPreventPurgingTheOwnerAndRollbackChildDeletion( + boolean bulk, TestNamespace ns) { + var client = SdkClients.adminClient(); + Ontology ontology = createOntology(ns, "retained" + bulk); + OntologyConcept concept = concept(ontology); + var release = capture(ontology); + var task = submit(ns, Entity.ONTOLOGY_RELEASE, release.getFullyQualifiedName()); + final RuntimeException failure; + if (bulk) { + failure = + assertThrows( + BadRequestException.class, + () -> + Entity.getEntityRepository(Entity.ONTOLOGY) + .bulkHardDeleteSubtree(List.of(ontology.getId()), "admin")); + } else { + final var response = + assertThrows( + OpenMetadataException.class, + () -> + client + .ontologies() + .delete( + ontology.getId().toString(), + Map.of("hardDelete", "true", "recursive", "true"))); + assertEquals(400, response.getStatusCode()); + failure = response; + } + assertTrue(failure.getMessage().contains("retained releases")); + assertEquals(ontology.getVersion(), current(ontology).getVersion()); + assertFalse(client.ontologyConcepts().get(concept.getId().toString()).getDeleted()); + assertEquals( + release.getContentDigest(), + client.ontologyReleases().get(release.getId()).getContentDigest()); + assertEquals( + TaskEntityStatus.Approved, + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved)) + .getStatus()); + } + + @Test + void archivedOntologiesRemainReadableButCannotStartOrCompleteReviews(TestNamespace ns) { + var client = SdkClients.adminClient(); + Ontology ontology = createOntology(ns); + var release = capture(ontology); + var draft = capture(ontology); + var task = submit(ns, Entity.ONTOLOGY_RELEASE, release.getFullyQualifiedName()); + client.ontologies().delete(ontology.getId().toString()); + final var purgeFailure = + assertThrows( + OpenMetadataException.class, + () -> + client + .ontologies() + .delete(ontology.getId().toString(), Map.of("hardDelete", "true"))); + assertEquals(400, purgeFailure.getStatusCode()); + assertTrue(purgeFailure.getMessage().contains("retained releases")); + assertEquals( + release.getContentDigest(), + client.ontologyReleases().get(release.getId()).getContentDigest()); + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved))); + assertThrows( + OpenMetadataException.class, + () -> + client + .tasks() + .create(reviewRequest(ns, Entity.ONTOLOGY_RELEASE, draft.getFullyQualifiedName()))); + new OntologyReviewReconciler(Entity.getCollectionDAO()).reconcile(task.getId()); + assertEquals( + EntityStatus.IN_REVIEW, client.ontologyReleases().get(release.getId()).getEntityStatus()); + assertEquals(TaskEntityStatus.Open, client.tasks().get(task.getId().toString()).getStatus()); + client.ontologies().restore(ontology.getId()); + assertEquals( + TaskEntityStatus.Approved, + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved)) + .getStatus()); + } + + private static OntologyConcept concept(Ontology ontology) { + return SdkClients.adminClient() + .ontologyConcepts() + .create( + new CreateOntologyConcept() + .withName("Customer") + .withOntology(ontology.getFullyQualifiedName()) + .withConceptType(OntologyConceptType.OWL_CLASS)); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReviewRecoveryIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReviewRecoveryIT.java new file mode 100644 index 000000000000..1d6179453c72 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReviewRecoveryIT.java @@ -0,0 +1,314 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.openmetadata.it.bootstrap.SharedEntities; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.TaskCategory; +import org.openmetadata.schema.type.TaskEntityStatus; +import org.openmetadata.schema.type.TaskEntityType; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.service.Entity; +import org.openmetadata.service.governance.workflows.OntologyApprovalWorkflow; +import org.openmetadata.service.governance.workflows.OntologyReviewReconciler; +import org.openmetadata.service.governance.workflows.OntologyWorkflowDecision; +import org.openmetadata.service.governance.workflows.WorkflowHandler; +import org.openmetadata.service.governance.workflows.WorkflowHandler.TaskResolutionOutcome; +import org.openmetadata.service.jdbi3.TaskRepository; + +@Execution(ExecutionMode.CONCURRENT) +@ExtendWith(TestNamespaceExtension.class) +public class IndependentOntologyReviewRecoveryIT extends BaseOntologyApprovalIT { + @Test + void committedWorkflowDecisionIsRecoveredWithoutVotingAgain(TestNamespace ns) { + OntologyRelease ontology = capture(createOntology(ns)); + Task task = submit(ns, Entity.ONTOLOGY_RELEASE, ontology.getFullyQualifiedName()); + completeWorkflowOnly(task); + OntologyReviewReconciler reconciler = new OntologyReviewReconciler(Entity.getCollectionDAO()); + + reconciler.reconcile(task.getId()); + OntologyRelease approved = current(ontology); + assertEquals(EntityStatus.APPROVED, approved.getEntityStatus()); + assertEquals( + TaskEntityStatus.Approved, + SdkClients.adminClient().tasks().get(task.getId().toString()).getStatus()); + reconciler.reconcile(task.getId()); + assertEquals(approved.getVersion(), current(ontology).getVersion()); + assertNull( + Entity.getCollectionDAO() + .entityExtensionDAO() + .getExtension(task.getId(), OntologyReviewReconciler.EXTENSION)); + } + + @Test + void simultaneousRecoveryAttemptsApplyOneDecision(TestNamespace ns) throws Exception { + OntologyRelease ontology = capture(createOntology(ns)); + Task task = submit(ns, Entity.ONTOLOGY_RELEASE, ontology.getFullyQualifiedName()); + completeWorkflowOnly(task); + reconcileConcurrently(task); + + assertEquals(EntityStatus.APPROVED, current(ontology).getEntityStatus()); + assertEquals(0.3, current(ontology).getVersion()); + assertEquals( + TaskEntityStatus.Approved, + SdkClients.adminClient().tasks().get(task.getId().toString()).getStatus()); + } + + private static void reconcileConcurrently(Task task) throws Exception { + CountDownLatch start = new CountDownLatch(1); + try (var workers = Executors.newVirtualThreadPerTaskExecutor()) { + List> recoveries = + IntStream.range(0, 4) + .mapToObj( + index -> + workers.submit( + () -> { + assertTrue(start.await(30, TimeUnit.SECONDS)); + new OntologyReviewReconciler(Entity.getCollectionDAO()) + .reconcile(task.getId()); + return null; + })) + .toList(); + start.countDown(); + for (Future recovery : recoveries) { + recovery.get(30, TimeUnit.SECONDS); + } + } + } + + @Test + void recoveryUsesTheCapturedReleaseAfterFurtherWorkingEdits(TestNamespace ns) { + Ontology root = createOntology(ns); + OntologyRelease release = capture(root); + Task task = submit(ns, Entity.ONTOLOGY_RELEASE, release.getFullyQualifiedName()); + completeWorkflowOnly(task); + SdkClients.adminClient() + .ontologies() + .update(root.getId(), current(root).withDescription("Next release")); + new OntologyReviewReconciler(Entity.getCollectionDAO()).reconcile(task.getId()); + assertEquals(EntityStatus.APPROVED, current(release).getEntityStatus()); + assertEquals(release.getContentDigest(), current(release).getContentDigest()); + assertEquals( + TaskEntityStatus.Approved, SdkClients.adminClient().tasks().get(task.getId()).getStatus()); + } + + @Test + void cancelledReviewRemovesItsWorkflowExecutions(TestNamespace ns) { + OntologyRelease ontology = capture(createOntology(ns)); + Task task = submit(ns, Entity.ONTOLOGY_RELEASE, ontology.getFullyQualifiedName()); + assertTrue(WorkflowHandler.getInstance().hasActiveRuntimeTask(task.getId())); + SdkClients.adminClient().tasks().close(task.getId().toString()); + + OntologyReviewReconciler reconciler = new OntologyReviewReconciler(Entity.getCollectionDAO()); + Awaitility.await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + reconciler.reconcile(task.getId()); + assertEquals( + 0, + WorkflowHandler.getInstance() + .getRuntimeService() + .createProcessInstanceQuery() + .processInstanceBusinessKey(task.getWorkflowInstanceId().toString()) + .count()); + }); + reconciler.reconcile(task.getId()); + assertEquals(EntityStatus.DRAFT, current(ontology).getEntityStatus()); + assertNull( + Entity.getCollectionDAO() + .entityExtensionDAO() + .getExtension(task.getId(), OntologyReviewReconciler.EXTENSION)); + } + + @Test + void workflowFailureBeforeTheFirstVoteReturnsTheDefinitionToDraft(TestNamespace ns) { + OntologyRelease ontology = capture(createOntology(ns)); + Task task = submit(ns, Entity.ONTOLOGY_RELEASE, ontology.getFullyQualifiedName()); + WorkflowHandler workflow = WorkflowHandler.getInstance(); + String processId = workflow.approvalTaskContext(task.getId()).processInstanceId(); + workflow.getRuntimeService().deleteProcessInstance(processId, "Failure before the first vote"); + + new OntologyReviewReconciler(Entity.getCollectionDAO()).reconcile(task.getId()); + + assertEquals(EntityStatus.DRAFT, current(ontology).getEntityStatus()); + assertEquals( + TaskEntityStatus.Cancelled, + SdkClients.adminClient().tasks().get(task.getId().toString()).getStatus()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void activeReviewCannotBeDeletedBeforeItIsClosed(boolean hardDelete, TestNamespace ns) { + OntologyRelease ontology = capture(createOntology(ns, "deleteTask" + hardDelete)); + Task task = submit(ns, Entity.ONTOLOGY_RELEASE, ontology.getFullyQualifiedName()); + + OpenMetadataException rejected = + assertThrows( + OpenMetadataException.class, + () -> + SdkClients.adminClient() + .tasks() + .delete( + task.getId().toString(), + Map.of("hardDelete", Boolean.toString(hardDelete)))); + + assertEquals(400, rejected.getStatusCode()); + assertEquals(EntityStatus.IN_REVIEW, current(ontology).getEntityStatus()); + SdkClients.adminClient().tasks().close(task.getId().toString()); + assertEquals(EntityStatus.DRAFT, current(ontology).getEntityStatus()); + } + + @Test + void archivingTheOntologyLeavesItsReleaseReviewRecoverable(TestNamespace ns) { + OntologyRelease ontology = capture(createOntology(ns)); + Task task = submit(ns, Entity.ONTOLOGY_RELEASE, ontology.getFullyQualifiedName()); + SdkClients.adminClient().ontologies().delete(ontology.getOntology().getId().toString()); + + new OntologyReviewReconciler(Entity.getCollectionDAO()).reconcile(task.getId()); + + assertEquals( + TaskEntityStatus.Open, + SdkClients.adminClient().tasks().get(task.getId().toString()).getStatus()); + SdkClients.adminClient().ontologies().restore(ontology.getOntology().getId()); + completeWorkflowOnly(task); + new OntologyReviewReconciler(Entity.getCollectionDAO()).reconcile(task.getId()); + assertEquals(EntityStatus.APPROVED, current(ontology).getEntityStatus()); + assertEquals( + TaskEntityStatus.Approved, + SdkClients.adminClient().tasks().get(task.getId().toString()).getStatus()); + } + + @Test + void aCrashBeforeWorkflowStartDoesNotStrandTheDefinition(TestNamespace ns) { + OntologyRelease ontology = capture(createOntology(ns)); + Task task = + persistUnstartedReview( + ns, + ontology, + System.currentTimeMillis() - Duration.ofMinutes(10).toMillis(), + TaskRepository.PENDING_WORKFLOW_START_STAGE_ID); + + new OntologyReviewReconciler(Entity.getCollectionDAO()).reconcile(task.getId()); + + assertEquals(EntityStatus.DRAFT, current(ontology).getEntityStatus()); + assertEquals( + TaskEntityStatus.Cancelled, + SdkClients.adminClient().tasks().get(task.getId().toString()).getStatus()); + } + + @Test + void aFailedWorkflowStartReturnsTheDefinitionToDraft(TestNamespace ns) { + OntologyRelease ontology = capture(createOntology(ns)); + Task task = + persistUnstartedReview( + ns, + ontology, + System.currentTimeMillis(), + TaskRepository.WORKFLOW_START_FAILED_STAGE_ID); + + new OntologyReviewReconciler(Entity.getCollectionDAO()).reconcile(task.getId()); + + assertEquals(EntityStatus.DRAFT, current(ontology).getEntityStatus()); + assertEquals( + TaskEntityStatus.Cancelled, + SdkClients.adminClient().tasks().get(task.getId().toString()).getStatus()); + } + + @Test + void aRecentSubmissionHasTimeToStartItsWorkflow(TestNamespace ns) { + OntologyRelease ontology = capture(createOntology(ns)); + Task task = + persistUnstartedReview( + ns, + ontology, + System.currentTimeMillis(), + TaskRepository.PENDING_WORKFLOW_START_STAGE_ID); + + new OntologyReviewReconciler(Entity.getCollectionDAO()).reconcile(task.getId()); + + assertEquals(EntityStatus.IN_REVIEW, current(ontology).getEntityStatus()); + assertEquals( + TaskEntityStatus.Open, + SdkClients.adminClient().tasks().get(task.getId().toString()).getStatus()); + } + + private static Task persistUnstartedReview( + TestNamespace ns, OntologyRelease ontology, long createdAt, String stage) { + TaskRepository tasks = (TaskRepository) Entity.getEntityRepository(Entity.TASK); + Task task = + new Task() + .withId(UUID.randomUUID()) + .withType(TaskEntityType.RequestApproval) + .withCategory(TaskCategory.Approval) + .withAbout(ontology.getEntityReference()) + .withCreatedBy(SdkClients.adminClient().users().getByName("admin").getEntityReference()) + .withCreatedAt(createdAt) + .withUpdatedAt(createdAt) + .withUpdatedBy("admin") + .withVersion(0.1) + .withDeleted(false); + tasks.prepareInternal(task, false); + task.setWorkflowStageId(stage); + tasks.executeInTransaction( + () -> { + tasks.storeEntity(task, false); + tasks.storeRelationships(task); + return null; + }); + return ns.trackRoot(Entity.TASK, task); + } + + private static void completeWorkflowOnly(Task snapshot) { + TaskRepository tasks = (TaskRepository) Entity.getEntityRepository(Entity.TASK); + Task task = tasks.get(null, snapshot.getId(), tasks.getFields("*")); + String user = SharedEntities.get().USER1.getName(); + OntologyApprovalWorkflow.validate(task, user); + OntologyApprovalWorkflow.validateRuntime(task); + WorkflowHandler workflow = WorkflowHandler.getInstance(); + Map variables = + workflow.transformToNodeVariables( + task.getId(), Map.of("result", "approve", "updatedBy", user)); + OntologyApprovalWorkflow.recordDecision( + task, + new OntologyWorkflowDecision( + task.getId(), + task.getOntologyReview(), + TaskResolutionType.Approved, + user, + List.of(user), + null, + "Committed before restart"), + variables); + TaskResolutionOutcome outcome = workflow.resolveTaskOutcome(task.getId(), variables); + assertTrue( + outcome == TaskResolutionOutcome.COMPLETED + || outcome == TaskResolutionOutcome.STAGE_ADVANCED); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/NativeOntologyMemoryIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/NativeOntologyMemoryIT.java new file mode 100644 index 000000000000..7424969789fb --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/NativeOntologyMemoryIT.java @@ -0,0 +1,255 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.openmetadata.it.bootstrap.SharedEntities; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.api.context.CreateContextMemory; +import org.openmetadata.schema.api.data.AcquireOntologyEditLock; +import org.openmetadata.schema.api.data.ApplyOntologyChangeSet; +import org.openmetadata.schema.api.data.CreateOntologyChangeSet; +import org.openmetadata.schema.entity.context.ContextMemoryStatus; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyChangeSet; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.type.OntologyChangeOperation; +import org.openmetadata.schema.type.OntologyChangeOperationState; +import org.openmetadata.schema.type.OntologyChangeOperationType; +import org.openmetadata.schema.type.OntologyChangeSetState; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyEditLeaseToken; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.RelationProvenance; +import org.openmetadata.sdk.client.OpenMetadataClient; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.services.context.ContextMemoryService; + +@ExtendWith(TestNamespaceExtension.class) +class NativeOntologyMemoryIT extends BaseOntologyApprovalIT { + @Test + void reviewedMemoryDraftsApplyDefinitionsAndAreIncludedInTheNextCapture(TestNamespace ns) { + final var client = SdkClients.adminClient(); + final Ontology ontology = + ns.trackRoot( + "ontology", + client + .ontologies() + .create( + request(ns) + .withOwners(List.of(SharedEntities.get().USER2.getEntityReference())))); + final var memories = new ContextMemoryService(client.getHttpClient()); + final var memory = + ns.trackRoot( + "contextMemory", + memories.create( + new CreateContextMemory() + .withName(ns.prefix("memory")) + .withQuestion("Who is a customer?") + .withAnswer("A customer is a party.") + .withEntityStatus(ContextMemoryStatus.APPROVED))); + final var customer = concept(ontology, "Customer"); + final var party = concept(ontology, "Party"); + final var property = + new OntologyProperty() + .withId(UUID.randomUUID()) + .withName("knows") + .withFullyQualifiedName(ontology.getFullyQualifiedName() + ".knows") + .withOntology(ontology.getEntityReference()) + .withIri(URI.create("urn:knows")) + .withKind(OntologyPropertyKind.OBJECT) + .withDomain(List.of(customer.getIri())) + .withRange(List.of(party.getIri())) + .withVersion(0.1); + final var axiom = axiom(ontology, customer, party); + final var operations = + List.of( + upsert(customer), + upsert(party), + operation(OntologyChangeOperationType.UPSERT_PROPERTY).withProperty(property), + operation(OntologyChangeOperationType.UPSERT_AXIOM).withAxiom(axiom)); + operations.forEach(operation -> operation.setSourceMemoryIds(Set.of(memory.getId()))); + final var draft = draft(ns, ontology, operations); + final var applied = apply(draft, SdkClients.user2Client()); + assertEquals(OntologyChangeSetState.APPLIED, applied.getState()); + assertEquals(4, applied.getApplicationResult().getOperationsApplied()); + assertEquals(5, capture(ontology).getMemberCount()); + assertEquals( + 4, memories.get(memory.getId().toString(), "derivedEntities").getDerivedEntities().size()); + + final var updated = client.ontologyConcepts().get(customer.getId().toString()); + updated.setDescription("A party buying products"); + final var changes = + List.of( + upsert(updated).withTargetId(updated.getId()).withBaseVersion(updated.getVersion()), + operation(OntologyChangeOperationType.DELETE_AXIOM) + .withTargetId(axiom.getId()) + .withBaseVersion(0.1), + operation(OntologyChangeOperationType.DELETE_PROPERTY) + .withTargetId(property.getId()) + .withBaseVersion(0.1), + operation(OntologyChangeOperationType.DELETE_CONCEPT) + .withTargetId(party.getId()) + .withBaseVersion(0.1)); + assertEquals( + OntologyChangeSetState.APPLIED, + apply(draft(ns, ontology, changes), SdkClients.user2Client()).getState()); + assertEquals( + "A party buying products", + client.ontologyConcepts().get(customer.getId().toString()).getDescription()); + assertEquals(2, capture(ontology).getMemberCount()); + } + + @Test + void invalidFinalModelRollsBackTheWholeDraft(TestNamespace ns) { + final var ontology = createOntology(ns); + final var first = concept(ontology, "First"); + final var second = concept(ontology, "Second"); + final var draft = + draft( + ns, + ontology, + List.of( + upsert(first), + upsert(second), + operation(OntologyChangeOperationType.UPSERT_AXIOM) + .withAxiom(axiom(ontology, first, second)), + operation(OntologyChangeOperationType.UPSERT_AXIOM) + .withAxiom(axiom(ontology, second, first)))); + final long revision = + SdkClients.adminClient().ontologies().state(ontology.getId()).getWorkingRevision(); + assertThrows(OpenMetadataException.class, () -> apply(draft)); + assertEquals( + revision, + SdkClients.adminClient().ontologies().state(ontology.getId()).getWorkingRevision()); + assertEquals(1, capture(ontology).getMemberCount()); + assertEquals( + OntologyChangeSetState.DRAFT, + SdkClients.adminClient().ontologyChangeSets().get(draft.getId().toString()).getState()); + } + + @Test + void staleDraftsCannotOverwriteWorkingEdits(TestNamespace ns) { + final var ontology = createOntology(ns); + final var draft = draft(ns, ontology, List.of(upsert(concept(ontology, "Proposed")))); + declareClass(ontology, "Concurrent", URI.create("urn:Concurrent")); + assertEquals( + 412, assertThrows(OpenMetadataException.class, () -> apply(draft)).getStatusCode()); + assertEquals(2, capture(ontology).getMemberCount()); + } + + @Test + void readersCannotProposeNativeDefinitionWrites(TestNamespace ns) { + final var ontology = createOntology(ns); + final var request = request(ns, ontology, List.of(upsert(concept(ontology, "Denied")))); + assertEquals( + 403, + assertThrows( + OpenMetadataException.class, + () -> SdkClients.user2Client().ontologyChangeSets().create(request)) + .getStatusCode()); + assertTrue( + SdkClients.adminClient().ontologies().state(ontology.getId()).getWorkingRevision() > 0); + } + + private static OntologyConcept concept(Ontology ontology, String name) { + return new OntologyConcept() + .withId(UUID.randomUUID()) + .withName(name) + .withFullyQualifiedName(ontology.getFullyQualifiedName() + "." + name) + .withOntology(ontology.getEntityReference()) + .withIri(URI.create("urn:" + name)) + .withConceptType(OntologyConceptType.OWL_CLASS) + .withVersion(0.1); + } + + private static OntologyAxiom axiom( + Ontology ontology, OntologyConcept child, OntologyConcept parent) { + return new OntologyAxiom() + .withId(UUID.randomUUID()) + .withName(child.getName() + "Is" + parent.getName()) + .withFullyQualifiedName( + ontology.getFullyQualifiedName() + "." + child.getName() + "Is" + parent.getName()) + .withDisplayName("Subclass") + .withDescription("A named subclass assertion") + .withProvenance(RelationProvenance.MANUAL) + .withOntology(ontology.getEntityReference()) + .withAxiomType(OntologyAxiomType.SUBCLASS_OF) + .withSubjectIri(child.getIri()) + .withExpressions( + List.of( + new OntologyExpression() + .withKind(OntologyExpressionKind.NAMED_CLASS) + .withClassIri(parent.getIri()))) + .withVersion(0.1); + } + + private static OntologyChangeOperation upsert(OntologyConcept concept) { + return operation(OntologyChangeOperationType.UPSERT_CONCEPT).withConcept(concept); + } + + private static OntologyChangeOperation operation(OntologyChangeOperationType type) { + return new OntologyChangeOperation() + .withId(UUID.randomUUID()) + .withOperationType(type) + .withState(OntologyChangeOperationState.ACTIVE); + } + + private static CreateOntologyChangeSet request( + TestNamespace ns, Ontology ontology, List operations) { + return new CreateOntologyChangeSet() + .withName(ns.prefix("draft") + UUID.randomUUID()) + .withDisplayName("Native memory proposal") + .withDescription("Definitions proposed from reviewed context memories") + .withOntology(ontology.getFullyQualifiedName()) + .withBaseWorkingRevision( + SdkClients.adminClient().ontologies().state(ontology.getId()).getWorkingRevision()) + .withOperations(operations); + } + + private static OntologyChangeSet draft( + TestNamespace ns, Ontology ontology, List operations) { + return ns.trackRoot( + "ontologyChangeSet", + SdkClients.adminClient().ontologyChangeSets().create(request(ns, ontology, operations))); + } + + private static OntologyChangeSet apply(OntologyChangeSet draft) { + return apply(draft, SdkClients.adminClient()); + } + + private static OntologyChangeSet apply(OntologyChangeSet draft, OpenMetadataClient client) { + final var lease = + client + .ontologyEditLocks() + .acquire( + new AcquireOntologyEditLock() + .withResourceType("ontologyChangeSet") + .withResourceId(draft.getId()) + .withSessionId(UUID.randomUUID().toString()) + .withLeaseSeconds(60)); + return client + .ontologyChangeSets() + .apply( + draft.getId(), + new ApplyOntologyChangeSet() + .withLease( + new OntologyEditLeaseToken() + .withSessionId(lease.getSessionId()) + .withVersion(lease.getVersion()))); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyAxiomResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyAxiomResourceIT.java index c120c2415cfe..507c0e48fb3f 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyAxiomResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyAxiomResourceIT.java @@ -15,29 +15,28 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.URI; import java.util.List; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; -import org.openmetadata.it.factories.GlossaryTestFactory; -import org.openmetadata.it.util.NamespaceCleanup; import org.openmetadata.it.util.SdkClients; import org.openmetadata.it.util.TestNamespace; import org.openmetadata.it.util.TestNamespaceExtension; import org.openmetadata.schema.api.data.CreateOntologyAxiom; +import org.openmetadata.schema.api.data.CreateOntologyProperty; import org.openmetadata.schema.api.data.OntologyProfileReport; -import org.openmetadata.schema.entity.data.Glossary; +import org.openmetadata.schema.entity.data.Ontology; import org.openmetadata.schema.entity.data.OntologyAxiom; -import org.openmetadata.schema.type.EntityStatus; import org.openmetadata.schema.type.OntologyAxiomType; import org.openmetadata.schema.type.OntologyExpression; import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyPropertyKind; import org.openmetadata.schema.type.OntologyRestrictionKind; import org.openmetadata.schema.type.RelationProvenance; import org.openmetadata.sdk.client.OpenMetadataClient; @@ -46,39 +45,30 @@ /** Live OWL 2 profile and persistence coverage for typed ontology axioms. */ @Execution(ExecutionMode.CONCURRENT) @ExtendWith(TestNamespaceExtension.class) -public class OntologyAxiomResourceIT { - private static final String ONTOLOGY_AXIOM = "ontologyAxiom"; +public class OntologyAxiomResourceIT extends BaseOntologyApprovalIT { private static final URI SUBJECT_IRI = URI.create("https://example.org/model/RegulatedProduct"); private static final URI PROPERTY_IRI = URI.create("https://example.org/model/hasJurisdiction"); private static final URI FILLER_IRI = URI.create("https://example.org/model/Jurisdiction"); - @AfterEach - void cleanup(TestNamespace ns) { - NamespaceCleanup.deleteRoots(ns.drainTrackedRoots()); - } - @Test - void axiomCreatedWithoutAStageStartsInDraft(TestNamespace ns) { + void workingAxiomsHaveNoApprovalStage(TestNamespace ns) { OpenMetadataClient client = SdkClients.adminClient(); - Glossary glossary = GlossaryTestFactory.createSimple(ns); - CreateOntologyAxiom request = - restrictionRequest(glossary, ns.prefix("draftByDefault")).withEntityStatus(null); + Ontology ontology = createModel(ns); + CreateOntologyAxiom request = restrictionRequest(ontology, ns.prefix("workingAxiom")); OntologyAxiom created = client.ontologyAxioms().create(request); - ns.trackRoot(ONTOLOGY_AXIOM, created); - assertEquals(EntityStatus.DRAFT, created.getEntityStatus()); + assertNull(created.getEntityStatus()); } @Test void validatesAndPersistsAQualifiedCardinalityRestriction(TestNamespace ns) { OpenMetadataClient client = SdkClients.adminClient(); - Glossary glossary = GlossaryTestFactory.createSimple(ns); - CreateOntologyAxiom request = restrictionRequest(glossary, ns.prefix("jurisdictionRule")); + Ontology ontology = createModel(ns); + CreateOntologyAxiom request = restrictionRequest(ontology, ns.prefix("jurisdictionRule")); OntologyProfileReport report = client.ontologyAxioms().validate(request); OntologyAxiom created = client.ontologyAxioms().create(request); - ns.trackRoot(ONTOLOGY_AXIOM, created); OntologyAxiom stored = client.ontologyAxioms().get(created.getId().toString()); assertTrue(report.getValid()); @@ -92,9 +82,9 @@ void validatesAndPersistsAQualifiedCardinalityRestriction(TestNamespace ns) { @Test void rejectsReservedVocabularyBeforePersistence(TestNamespace ns) { OpenMetadataClient client = SdkClients.adminClient(); - Glossary glossary = GlossaryTestFactory.createSimple(ns); + Ontology ontology = createModel(ns); CreateOntologyAxiom request = - restrictionRequest(glossary, ns.prefix("reservedRule")) + restrictionRequest(ontology, ns.prefix("reservedRule")) .withSubjectIri(URI.create("http://www.w3.org/2002/07/owl#Thing")); OpenMetadataException exception = @@ -103,7 +93,22 @@ void rejectsReservedVocabularyBeforePersistence(TestNamespace ns) { assertFalse(exception.getMessage().isBlank()); } - private static CreateOntologyAxiom restrictionRequest(Glossary glossary, String name) { + private static Ontology createModel(TestNamespace ns) { + final var ontology = createOntology(ns); + declareClass(ontology, "RegulatedProduct", SUBJECT_IRI); + declareClass(ontology, "Jurisdiction", FILLER_IRI); + SdkClients.adminClient() + .ontologyProperties() + .create( + new CreateOntologyProperty() + .withName("hasJurisdiction") + .withOntology(ontology.getFullyQualifiedName()) + .withIri(PROPERTY_IRI) + .withKind(OntologyPropertyKind.OBJECT)); + return ontology; + } + + private static CreateOntologyAxiom restrictionRequest(Ontology ontology, String name) { OntologyExpression filler = new OntologyExpression() .withKind(OntologyExpressionKind.NAMED_CLASS) @@ -119,11 +124,10 @@ private static CreateOntologyAxiom restrictionRequest(Glossary glossary, String .withName(name) .withDisplayName("Jurisdiction requirement") .withDescription("Every regulated product has exactly one governing jurisdiction") - .withGlossary(glossary.getFullyQualifiedName()) + .withOntology(ontology.getFullyQualifiedName()) .withAxiomType(OntologyAxiomType.SUBCLASS_OF) .withSubjectIri(SUBJECT_IRI) .withExpressions(List.of(restriction)) - .withProvenance(RelationProvenance.MANUAL) - .withEntityStatus(EntityStatus.DRAFT); + .withProvenance(RelationProvenance.MANUAL); } } diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyConceptResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyConceptResourceIT.java new file mode 100644 index 000000000000..40b181d67a67 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyConceptResourceIT.java @@ -0,0 +1,111 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; +import java.util.Map; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.schema.api.data.CreateOntology; +import org.openmetadata.schema.api.data.CreateOntologyConcept; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.sdk.models.ListParams; +import org.openmetadata.sdk.models.ListResponse; +import org.openmetadata.sdk.services.ontology.OntologyConceptService; + +@Execution(ExecutionMode.CONCURRENT) +public class OntologyConceptResourceIT + extends BaseOntologyIT { + @Override + protected OntologyConceptService entityService() { + return SdkClients.adminClient().ontologyConcepts(); + } + + @Override + protected CreateOntologyConcept createMinimalRequest(TestNamespace ns) { + return createRequest(ns.prefix("ontologyConcept"), ns); + } + + @Override + protected CreateOntologyConcept createRequest(String name, TestNamespace ns) { + return new CreateOntologyConcept() + .withName(name) + .withDescription("Ontology definition") + .withIri(definitionIri(name, ns)) + .withOntology(parent(ns).getFullyQualifiedName()) + .withConceptType(OntologyConceptType.OWL_CLASS); + } + + private static Ontology parent(TestNamespace ns) { + Ontology ontology = + SdkClients.adminClient() + .ontologies() + .upsert( + new CreateOntology() + .withName(ns.prefix("ontology")) + .withIri(URI.create("https://example.org/ontology/" + ns.shortPrefix())) + .withNamespace(URI.create("https://example.org/ontology/"))); + return ns.trackRoot("ontology", ontology); + } + + @Override + protected OntologyConcept createEntity(CreateOntologyConcept request) { + return SdkClients.adminClient().ontologyConcepts().create(request); + } + + @Override + protected OntologyConcept getEntity(String id) { + return SdkClients.adminClient().ontologyConcepts().get(id); + } + + @Override + protected OntologyConcept getEntityByName(String name) { + return SdkClients.adminClient().ontologyConcepts().getByName(name); + } + + @Override + protected ListResponse listEntities(ListParams params) { + return SdkClients.adminClient().ontologyConcepts().list(params); + } + + @Override + protected OntologyConcept patchEntity(String id, OntologyConcept entity) { + return SdkClients.adminClient().ontologyConcepts().update(id, entity); + } + + @Override + protected void deleteEntity(String id) { + SdkClients.adminClient().ontologyConcepts().delete(id); + } + + @Override + protected void restoreEntity(String id) { + SdkClients.adminClient().ontologyConcepts().restore(id); + } + + @Override + protected void hardDeleteEntity(String id) { + SdkClients.adminClient() + .ontologyConcepts() + .delete(id, Map.of("hardDelete", "true", "recursive", "true")); + } + + @Override + protected String getEntityType() { + return "ontologyConcept"; + } + + @Override + protected String getResourcePath() { + return "/v1/ontologyConcepts/"; + } + + @Override + protected void validateCreatedEntity(OntologyConcept entity, CreateOntologyConcept request) { + assertEquals(request.getIri(), entity.getIri()); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyPropertyResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyPropertyResourceIT.java new file mode 100644 index 000000000000..bb6afa51955f --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyPropertyResourceIT.java @@ -0,0 +1,302 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.schema.api.data.CreateOntology; +import org.openmetadata.schema.api.data.CreateOntologyProperty; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.OntologyLiteral; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.RelationshipCharacteristic; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.models.ListParams; +import org.openmetadata.sdk.models.ListResponse; +import org.openmetadata.sdk.services.ontology.OntologyPropertyService; + +@Execution(ExecutionMode.CONCURRENT) +public class OntologyPropertyResourceIT + extends BaseOntologyIT { + private void assertPropertyFieldRejected(OntologyProperty property, String field, Object value) { + final var patch = JsonUtils.getObjectMapper().createArrayNode(); + patch + .addObject() + .put("op", "replace") + .put("path", "/" + field) + .set("value", JsonUtils.valueToTree(value)); + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, () -> entityService().patch(property.getId(), patch)) + .getStatusCode()); + } + + @Test + void dataValuesRoundTripAndEditsCreateANewVersion(TestNamespace ns) { + final List values = + List.of( + new OntologyLiteral() + .withValue("01") + .withDatatypeIri(URI.create("http://www.w3.org/2001/XMLSchema#integer")), + new OntologyLiteral() + .withValue("2") + .withDatatypeIri(URI.create("http://www.w3.org/2001/XMLSchema#integer"))); + final OntologyProperty created = + createEntity( + createMinimalRequest(ns) + .withKind(OntologyPropertyKind.DATA) + .withEnumValues(values) + .withUnit("kg") + .withIsIdentifier(true)); + final OntologyProperty stored = getEntity(created.getId().toString()); + assertEquals(values, stored.getEnumValues()); + assertEquals("kg", stored.getUnit()); + assertEquals(true, stored.getIsIdentifier()); + stored.setEnumValues(List.of(values.getLast())); + stored.setUnit("g"); + final OntologyProperty updated = entityService().update(stored.getId().toString(), stored); + assertEquals(List.of(values.getLast()), updated.getEnumValues()); + assertEquals("g", updated.getUnit()); + assertTrue(updated.getVersion() > created.getVersion()); + } + + @Test + void enumerationMetadataIsValidatedOnEveryWrite(TestNamespace ns) { + final CreateOntologyProperty request = + createMinimalRequest(ns).withEnumValues(List.of(new OntologyLiteral().withValue("Gold"))); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> createEntity(request)).getStatusCode()); + request.withEnumValues(List.of()).withUnit("kg"); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> createEntity(request)).getStatusCode()); + request.withUnit(null).withIsIdentifier(true); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> createEntity(request)).getStatusCode()); + final OntologyProperty created = createEntity(request.withKind(OntologyPropertyKind.DATA)); + created.setEnumValues( + List.of( + new OntologyLiteral() + .withValue("one") + .withDatatypeIri(URI.create("http://www.w3.org/2001/XMLSchema#integer")))); + assertPropertyFieldRejected(created, "enumValues", created.getEnumValues()); + created.setEnumValues( + List.of(new OntologyLiteral().withValue("Gold"), new OntologyLiteral().withValue("Gold"))); + assertPropertyFieldRejected(created, "enumValues", created.getEnumValues()); + assertEquals(List.of(), getEntity(created.getId().toString()).getEnumValues()); + } + + @Test + void propertyReferencesPreserveTheirExactIriSpelling(TestNamespace ns) { + List spellings = + List.of("HTTPS://EXAMPLE.org/客户%2fOriginal", "https://example.org/客户%2fOriginal"); + List references = spellings.stream().map(URI::create).toList(); + final var request = createMinimalRequest(ns); + declareProperties(request, references); + final var ontology = SdkClients.adminClient().ontologies().getByName(request.getOntology()); + for (int index = 0; index < references.size(); index++) { + BaseOntologyApprovalIT.declareClass(ontology, "Class" + index, references.get(index)); + } + OntologyProperty created = + createEntity( + request + .withDomain(references) + .withRange(references) + .withDisjointWith(references) + .withInverse(references.getFirst())); + final var chain = + createEntity( + new CreateOntologyProperty() + .withName("Chain") + .withOntology(request.getOntology()) + .withKind(OntologyPropertyKind.OBJECT) + .withPropertyChain(references)); + OntologyProperty stored = getEntity(created.getId().toString()); + assertEquals(spellings, stored.getDomain().stream().map(URI::toString).toList()); + assertEquals(spellings, stored.getRange().stream().map(URI::toString).toList()); + assertEquals(spellings, stored.getDisjointWith().stream().map(URI::toString).toList()); + assertEquals( + spellings, + getEntity(chain.getId().toString()).getPropertyChain().stream() + .map(URI::toString) + .toList()); + assertEquals(spellings.getFirst(), stored.getInverse().toString()); + + stored.setDomain(List.of(references.getLast())); + entityService().update(stored.getId().toString(), stored); + assertEquals( + List.of(spellings.getLast()), + getEntity(stored.getId().toString()).getDomain().stream().map(URI::toString).toList()); + } + + @Test + void propertySetsRejectDuplicateIrisButChainsAllowRepetition(TestNamespace ns) { + URI iri = URI.create("https://example.org/Customer"); + List duplicates = List.of(iri, iri); + CreateOntologyProperty request = createMinimalRequest(ns).withDomain(duplicates); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> createEntity(request)).getStatusCode()); + request.withDomain(null).withRange(duplicates); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> createEntity(request)).getStatusCode()); + request.withRange(null).withDisjointWith(duplicates); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> createEntity(request)).getStatusCode()); + declareProperties(request, List.of(iri)); + OntologyProperty property = + createEntity(request.withDisjointWith(null).withPropertyChain(duplicates)); + assertEquals(duplicates, getEntity(property.getId().toString()).getPropertyChain()); + } + + @Test + void propertyKindConstrainsItsCharacteristics(TestNamespace ns) { + CreateOntologyProperty request = + createMinimalRequest(ns) + .withKind(OntologyPropertyKind.ANNOTATION) + .withCharacteristics(Set.of(RelationshipCharacteristic.FUNCTIONAL)); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> createEntity(request)).getStatusCode()); + OntologyProperty property = createEntity(request.withKind(OntologyPropertyKind.DATA)); + + property.setCharacteristics(Set.of(RelationshipCharacteristic.TRANSITIVE)); + assertPropertyFieldRejected(property, "characteristics", property.getCharacteristics()); + assertEquals( + Set.of(RelationshipCharacteristic.FUNCTIONAL), + getEntity(property.getId().toString()).getCharacteristics()); + } + + @Test + void propertyChainsRequireAtLeastTwoObjectProperties(TestNamespace ns) { + CreateOntologyProperty request = + createMinimalRequest(ns) + .withPropertyChain(List.of(URI.create("https://example.org/first"))); + assertEquals( + 400, + assertThrows(OpenMetadataException.class, () -> createEntity(request)).getStatusCode()); + List chain = + List.of(URI.create("https://example.org/first"), URI.create("https://example.org/second")); + declareProperties(request, chain); + OntologyProperty property = createEntity(request.withPropertyChain(chain)); + assertEquals(chain, getEntity(property.getId().toString()).getPropertyChain()); + } + + private static void declareProperties(CreateOntologyProperty owner, List iris) { + for (int index = 0; index < iris.size(); index++) { + SdkClients.adminClient() + .ontologyProperties() + .create( + new CreateOntologyProperty() + .withOntology(owner.getOntology()) + .withName("Reference" + index) + .withIri(iris.get(index)) + .withKind(OntologyPropertyKind.OBJECT)); + } + } + + @Override + protected OntologyPropertyService entityService() { + return SdkClients.adminClient().ontologyProperties(); + } + + @Override + protected CreateOntologyProperty createMinimalRequest(TestNamespace ns) { + return createRequest(ns.prefix("ontologyProperty"), ns); + } + + @Override + protected CreateOntologyProperty createRequest(String name, TestNamespace ns) { + return new CreateOntologyProperty() + .withName(name) + .withDescription("Ontology definition") + .withIri(definitionIri(name, ns)) + .withOntology(parent(ns).getFullyQualifiedName()) + .withKind(OntologyPropertyKind.OBJECT); + } + + private static Ontology parent(TestNamespace ns) { + Ontology ontology = + SdkClients.adminClient() + .ontologies() + .upsert( + new CreateOntology() + .withName(ns.prefix("ontology")) + .withIri(URI.create("https://example.org/ontology/" + ns.shortPrefix())) + .withNamespace(URI.create("https://example.org/ontology/"))); + return ns.trackRoot("ontology", ontology); + } + + @Override + protected OntologyProperty createEntity(CreateOntologyProperty request) { + return SdkClients.adminClient().ontologyProperties().create(request); + } + + @Override + protected OntologyProperty getEntity(String id) { + return SdkClients.adminClient().ontologyProperties().get(id); + } + + @Override + protected OntologyProperty getEntityByName(String name) { + return SdkClients.adminClient().ontologyProperties().getByName(name); + } + + @Override + protected ListResponse listEntities(ListParams params) { + return SdkClients.adminClient().ontologyProperties().list(params); + } + + @Override + protected OntologyProperty patchEntity(String id, OntologyProperty entity) { + return SdkClients.adminClient().ontologyProperties().update(id, entity); + } + + @Override + protected void deleteEntity(String id) { + SdkClients.adminClient().ontologyProperties().delete(id); + } + + @Override + protected void restoreEntity(String id) { + SdkClients.adminClient().ontologyProperties().restore(id); + } + + @Override + protected void hardDeleteEntity(String id) { + SdkClients.adminClient() + .ontologyProperties() + .delete(id, Map.of("hardDelete", "true", "recursive", "true")); + } + + @Override + protected String getEntityType() { + return "ontologyProperty"; + } + + @Override + protected String getResourcePath() { + return "/v1/ontologyProperties/"; + } + + @Override + protected void validateCreatedEntity(OntologyProperty entity, CreateOntologyProperty request) { + assertEquals(request.getIri(), entity.getIri()); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfBlueGreenRebuildIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfBlueGreenRebuildIT.java index 9e564ac06225..b2a1283cf7fa 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfBlueGreenRebuildIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfBlueGreenRebuildIT.java @@ -478,6 +478,7 @@ void journalReplaysBatchTurtleSparqlAndGraphClearMutations(final Database databa 1024 * 1024); live.bulkStoreEntities( List.of(new EntityWriteRequest("table", id, model)), RdfWriteMode.RECONCILE); + live.replaceGraph("urn:journal:activeOntology", model); } finally { model.close(); } @@ -494,7 +495,8 @@ void journalReplaysBatchTurtleSparqlAndGraphClearMutations(final Database databa assertName(fixture.other.routedStorage(), id, "batch"); assertEquals(1, fixture.other.routedStorage().getTripleCount("urn:journal:turtle")); assertEquals(0, fixture.other.routedStorage().getTripleCount("urn:journal:clear")); - assertEquals(7, invalidations.get()); + assertEquals(1, fixture.other.routedStorage().getTripleCount("urn:journal:activeOntology")); + assertEquals(8, invalidations.get()); } } diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfFusekiAuthorizationIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfFusekiAuthorizationIT.java index e6693c99e07b..e4e24aec8883 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfFusekiAuthorizationIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfFusekiAuthorizationIT.java @@ -103,7 +103,33 @@ void writerCanUploadThroughGraphStore(final String dataset) throws Exception { HttpRequest.BodyPublishers.ofString( "<" + subject + "> ."))); assertEquals(2, response.statusCode() / 100, response.body()); - assertTrue(contains(dataset, subject)); + assertTrue(contains(dataset, GRAPH, subject)); + } + + @ParameterizedTest + @ValueSource(strings = {"openmetadata", "openmetadata_a", "openmetadata_b"}) + void graphStorePutReplacesAtomicallyWithWriterCredentials(final String dataset) throws Exception { + final String graph = "urn:review:replacement:" + UUID.randomUUID(); + final String first = "urn:review:" + UUID.randomUUID(); + final String second = "urn:review:" + UUID.randomUUID(); + assertEquals( + 2, replace(dataset, graph, "<" + first + "> .").statusCode() / 100); + assertEquals( + 2, replace(dataset, graph, "<" + second + "> .").statusCode() / 100); + assertFalse(contains(dataset, graph, first)); + assertTrue(contains(dataset, graph, second)); + final var invalid = replace(dataset, graph, " ."); + assertTrue(invalid.statusCode() >= 400, invalid.body()); + assertTrue(contains(dataset, graph, second)); + } + + private static HttpResponse replace( + final String dataset, final String graph, final String turtle) throws Exception { + return send( + request("/" + dataset + "/data?graph=" + graph) + .header("Authorization", basic("openmetadata:test-writer")) + .header("Content-Type", "text/turtle") + .PUT(HttpRequest.BodyPublishers.ofString(turtle))); } @ParameterizedTest @@ -111,7 +137,7 @@ void writerCanUploadThroughGraphStore(final String dataset) throws Exception { void anonymousUploadCannotMutateAnyDataset(final String dataset) throws Exception { final String subject = "urn:review:" + UUID.randomUUID(); assertEquals(401, send(upload(dataset, subject)).statusCode()); - assertFalse(contains(dataset, subject)); + assertFalse(contains(dataset, GRAPH, subject)); } private static HttpRequest.Builder upload(final String dataset, final String subject) { @@ -138,7 +164,8 @@ private static HttpRequest.Builder upload(final String dataset, final String sub .POST(HttpRequest.BodyPublishers.ofString(body)); } - private static boolean contains(final String dataset, final String subject) throws Exception { + private static boolean contains(final String dataset, final String graph, final String subject) + throws Exception { final var response = send( request("/" + dataset + "/query") @@ -146,7 +173,7 @@ private static boolean contains(final String dataset, final String subject) thro .header("Accept", "application/sparql-results+json") .POST( HttpRequest.BodyPublishers.ofString( - "ASK { GRAPH <" + GRAPH + "> { <" + subject + "> ?p ?o } }"))); + "ASK { GRAPH <" + graph + "> { <" + subject + "> ?p ?o } }"))); assertEquals(200, response.statusCode(), response.body()); return JsonUtils.readTree(response.body()).path("boolean").asBoolean(); } diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfLiveWriteRecoveryIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfLiveWriteRecoveryIT.java index 5ffc400e527b..0bb362f503b6 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfLiveWriteRecoveryIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfLiveWriteRecoveryIT.java @@ -25,6 +25,7 @@ import java.util.EnumMap; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -62,6 +63,43 @@ public class RdfLiveWriteRecoveryIT { private static GenericContainer fuseki; private static String endpoint; + @ParameterizedTest + @EnumSource(RdfTestDatabase.Backend.class) + void callerTransactionCommitsOrRollsBackItsOutboxAndWatermark( + final RdfTestDatabase.Backend backend) { + try (Fixture fixture = new Fixture(backend)) { + final long previous = fixture.primary.enqueuedWatermark(); + assertThrows( + IllegalStateException.class, + () -> + fixture + .database + .jdbi() + .useTransaction( + handle -> { + fixture.primary.enqueue(handle, insert(0)); + assertEquals( + 0, CompletableFuture.supplyAsync(fixture.other::pendingWrites).join()); + throw new IllegalStateException("metadata mutation failed"); + })); + assertEquals(0, fixture.other.pendingWrites()); + assertEquals(previous, fixture.other.enqueuedWatermark()); + fixture + .database + .jdbi() + .useHandle( + handle -> + assertThrows( + IllegalStateException.class, + () -> fixture.primary.enqueue(handle, insert(0)))); + fixture.database.jdbi().useTransaction(handle -> fixture.primary.enqueue(handle, insert(0))); + assertEquals(1, fixture.other.pendingWrites()); + assertTrue(fixture.other.processNext(fixture::apply)); + assertEquals(0, fixture.primary.pendingWrites()); + assertEquals(1, fixture.storage.getTripleCount(GRAPH)); + } + } + @BeforeAll static void startServices() { endpoint = System.getProperty("rdfRebuildFusekiEndpoint"); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfOntologyActivationIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfOntologyActivationIT.java new file mode 100644 index 000000000000..703f9ec9bb41 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfOntologyActivationIT.java @@ -0,0 +1,203 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Isolated; +import org.openmetadata.it.bootstrap.TestSuiteBootstrap; +import org.openmetadata.it.util.RdfTestUtils; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.api.data.ActivateOntologyRelease; +import org.openmetadata.schema.api.data.CreateOntologyAxiom; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.RelationProvenance; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.rdf.OntologyActiveGraph; +import org.openmetadata.service.rdf.RdfRepository; +import org.openmetadata.service.rdf.RdfUpdater; + +@Tag("rdf") +@EnabledIfSystemProperty(named = "enableRdf", matches = "true") +@Isolated +@ExtendWith(TestNamespaceExtension.class) +public class RdfOntologyActivationIT extends BaseOntologyApprovalIT { + @AfterEach + void restoreRdf() { + RdfTestUtils.restoreSuiteRdf(); + } + + @Test + void activationRollbackAndArchiveSurviveRebuildingTheServingDataset(TestNamespace ns) { + final var ontology = createOntology(ns); + final var customer = declareClass(ontology, "Customer", URI.create("urn:Customer")); + final var party = declareClass(ontology, "Party", URI.create("urn:Party")); + final var legal = declareClass(ontology, "LegalEntity", URI.create("urn:LegalEntity")); + final var client = SdkClients.adminClient(); + final var axiom = + client + .ontologyAxioms() + .create( + new CreateOntologyAxiom() + .withName("Parents") + .withDisplayName("Parents") + .withDescription("A named class hierarchy") + .withOntology(ontology.getFullyQualifiedName()) + .withSubjectIri(customer.getIri()) + .withAxiomType(OntologyAxiomType.SUBCLASS_OF) + .withProvenance(RelationProvenance.MANUAL) + .withExpressions(List.of(named(party.getIri())))); + final var first = approve(ns, capture(ontology)); + assertFalse(ask(ontology, "?s ?p ?o")); + activate(ontology, first, 0); + await( + ontology, + " ", + true); + assertTrue( + ask( + ontology, + "?s <" + first.getVersionIri() + ">")); + client + .ontologyAxioms() + .update(axiom.getId(), axiom.withExpressions(List.of(named(legal.getIri())))); + assertTrue( + ask( + ontology, + " ")); + assertFalse( + ask( + ontology, + " ")); + final var repository = RdfRepository.getInstance(); + final var build = repository.beginBlueGreenRebuild(); + try { + final var target = repository.forRun(build.dataset(), build.id(), 0); + target.clearAll(); + copyServingGraphs(repository, target); + target.reloadOntologies(); + target.rebuildOntologyGraphs(); + final var second = approve(ns, capture(ontology)); + activate(ontology, second, 1); + await( + ontology, + " ", + true); + repository.activateDataset(build.dataset(), build.id(), "admin"); + assertTrue( + ask( + ontology, + " ")); + assertFalse( + ask( + ontology, + " ")); + } catch (RuntimeException | AssertionError failure) { + repository.abandonBuild(build); + throw failure; + } + activate(ontology, first, 2); + await( + ontology, + " ", + true); + client.ontologies().delete(ontology.getId().toString(), Map.of("recursive", "true")); + await(ontology, "?s ?p ?o", false); + client.ontologies().restore(ontology.getId()); + await( + ontology, + " ", + true); + } + + @Test + void rdfDisabledActivationIsProjectedWhenTheGraphIsRebuilt(TestNamespace ns) { + RdfUpdater.disable(); + final var ontology = createOntology(ns); + final var release = approve(ns, capture(ontology)); + activate(ontology, release, 0); + RdfUpdater.initialize(TestSuiteBootstrap.getRdfConfiguration()); + assertFalse(ask(ontology, "?s ?p ?o")); + RdfRepository.getInstance().rebuildOntologyGraphs(); + assertTrue( + ask( + ontology, + "?s <" + release.getVersionIri() + ">")); + } + + private static OntologyRelease approve(TestNamespace ns, OntologyRelease release) { + final var task = submit(ns, Entity.ONTOLOGY_RELEASE, release.getFullyQualifiedName()); + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved)); + return release; + } + + private static void activate(Ontology ontology, OntologyRelease release, long revision) { + final var state = + SdkClients.adminClient() + .ontologies() + .activate( + ontology.getId(), + new ActivateOntologyRelease() + .withReleaseId(release.getId()) + .withExpectedActivationRevision(revision)); + assertEquals(release.getId(), state.getActiveReleaseId()); + } + + private static OntologyExpression named(URI iri) { + return new OntologyExpression().withKind(OntologyExpressionKind.NAMED_CLASS).withClassIri(iri); + } + + private static void copyServingGraphs(RdfRepository serving, RdfRepository target) { + final var graphs = + JsonUtils.readTree(serving.executeSparqlQuery("SELECT ?g WHERE { GRAPH ?g { } }", "json")) + .path("results") + .path("bindings"); + for (var graph : graphs) { + final String iri = graph.path("g").path("value").asText(); + final String triples = + serving.executeSparqlQuery( + "CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <" + iri + "> { ?s ?p ?o } }", "ntriples"); + target.executeSparqlUpdate("INSERT DATA { GRAPH <" + iri + "> { " + triples + " } }"); + } + } + + private static void await(Ontology ontology, String pattern, boolean expected) { + Awaitility.await() + .atMost(Duration.ofSeconds(30)) + .until(() -> ask(ontology, pattern) == expected); + } + + private static boolean ask(Ontology ontology, String pattern) { + return JsonUtils.readTree( + RdfRepository.getInstance() + .executeSparqlQuery( + "ASK { GRAPH <" + + OntologyActiveGraph.graph(ontology.getId()) + + "> { " + + pattern + + " } }", + "json")) + .path("boolean") + .asBoolean(); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/search/DbToEsCountReconciliationIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/search/DbToEsCountReconciliationIT.java index 54f8adf1dc62..4380fbed1293 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/search/DbToEsCountReconciliationIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/search/DbToEsCountReconciliationIT.java @@ -19,6 +19,7 @@ import org.openmetadata.it.factories.GlossaryTestFactory; import org.openmetadata.it.factories.TableTestFactory; import org.openmetadata.it.factories.TopicTestFactory; +import org.openmetadata.it.factories.UserTestFactory; import org.openmetadata.it.search.DbCountQuerier; import org.openmetadata.it.search.IndexAliasInspector; import org.openmetadata.it.search.ReindexHelpers; @@ -32,12 +33,13 @@ import org.openmetadata.schema.entity.app.AppRunRecord; import org.openmetadata.schema.entity.data.DatabaseSchema; import org.openmetadata.schema.entity.data.Glossary; +import org.openmetadata.schema.entity.teams.User; import org.openmetadata.sdk.fluent.Apps; /** * The umbrella correctness invariant: for every entity type that has both a * REST list endpoint and a search index alias, the post-reindex doc count in - * the index must equal the DB count. + * the index must equal the DB count, including soft-deleted entities on both sides. * *

This is the canonical gate. If a regression breaks any indexer * (relationship resolution, tag join, custom property serialization, @@ -118,7 +120,7 @@ private List collectCountMismatches() { if (inspector.indicesForAlias(alias).isEmpty()) { continue; } - final long dbCount = db.count(entityType); + final long dbCount = db.count(entityType, true); final long esCount = search.countByEntityType(alias, entityType); if (dbCount != esCount) { mismatches.add( @@ -135,5 +137,8 @@ private static void seedRepresentativeCohort(final TestNamespace ns) { TopicTestFactory.createSimple(ns); final Glossary glossary = GlossaryTestFactory.createSimple(ns); GlossaryTermTestFactory.createSimple(ns, glossary); + final User deletedUser = UserTestFactory.createUser(ns, ns.shortPrefix("deleted_user")); + SdkClients.adminClient().users().delete(deletedUser.getId()); + assertThat(db.count("user", true)).isGreaterThan(db.count("user")); } } diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/search/ReindexStatsIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/search/ReindexStatsIT.java index c5634309330e..12170724c2f1 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/search/ReindexStatsIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/search/ReindexStatsIT.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import org.awaitility.Awaitility; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -12,7 +13,9 @@ import org.junit.jupiter.api.parallel.ResourceLock; import org.openmetadata.it.factories.DatabaseSchemaTestFactory; import org.openmetadata.it.factories.TableTestFactory; +import org.openmetadata.it.search.IndexAliasInspector; import org.openmetadata.it.search.ReindexHelpers; +import org.openmetadata.it.search.SearchAssertions; import org.openmetadata.it.search.SearchClusterResetExtension; import org.openmetadata.it.server.ServerHandle; import org.openmetadata.it.util.OssTestServer; @@ -106,12 +109,17 @@ void orphanedSchemaDoesNotFailReindex(final TestNamespace ns) { table.getFullyQualifiedName()) .isZero(); } finally { - // The schema row was force-deleted above, so the table is now orphaned. The namespace's - // recursive service cleanup (service -> db -> schema -> table) can't reach it through the - // broken chain, leaving a non-deleted, unindexable table on the shared cluster that later - // skews the cluster-wide DbToEsCountReconciliationIT (table db=2/es=1). Remove it directly, - // mirroring the DAO-level surgery used to create the orphan. + // The missing schema prevents namespace cleanup from reaching the table or its search doc. Entity.getCollectionDAO().tableDAO().delete(table.getId()); + Entity.getSearchRepository().deleteEntityIndex(table); + final String alias = new IndexAliasInspector(server).aliasFor(Entity.TABLE); + final SearchAssertions search = new SearchAssertions(server); + Awaitility.await("orphaned table removed from search") + .atMost(ReindexHelpers.searchPropagationTimeout()) + .untilAsserted( + () -> + search.assertEntityNotIndexed( + alias, Entity.TABLE, table.getFullyQualifiedName())); } } diff --git a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/client/OpenMetadataClient.java b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/client/OpenMetadataClient.java index 9af3ccfc4ed6..4036899c8820 100644 --- a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/client/OpenMetadataClient.java +++ b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/client/OpenMetadataClient.java @@ -56,12 +56,16 @@ import org.openmetadata.sdk.services.ontology.OntologyAxiomService; import org.openmetadata.sdk.services.ontology.OntologyBulkService; import org.openmetadata.sdk.services.ontology.OntologyChangeSetService; +import org.openmetadata.sdk.services.ontology.OntologyConceptService; import org.openmetadata.sdk.services.ontology.OntologyEditLockService; import org.openmetadata.sdk.services.ontology.OntologyImpactService; import org.openmetadata.sdk.services.ontology.OntologyModelingService; import org.openmetadata.sdk.services.ontology.OntologyPackService; import org.openmetadata.sdk.services.ontology.OntologyPatternService; +import org.openmetadata.sdk.services.ontology.OntologyPropertyService; import org.openmetadata.sdk.services.ontology.OntologyReasoningService; +import org.openmetadata.sdk.services.ontology.OntologyReleaseService; +import org.openmetadata.sdk.services.ontology.OntologyService; import org.openmetadata.sdk.services.ontology.OntologyStructureService; import org.openmetadata.sdk.services.ontology.OntologySubsetService; import org.openmetadata.sdk.services.ontology.RelationshipTypeService; @@ -144,6 +148,10 @@ public class OpenMetadataClient { // Ontology private final RelationshipTypeService relationshipTypes; private final OntologyAxiomService ontologyAxioms; + private final OntologyService ontologies; + private final OntologyConceptService ontologyConcepts; + private final OntologyPropertyService ontologyProperties; + private final OntologyReleaseService ontologyReleases; private final OntologyAiService ontologyAi; private final OntologyBulkService ontologyBulk; private final OntologyChangeSetService ontologyChangeSets; @@ -287,6 +295,10 @@ public OpenMetadataClient(OpenMetadataConfig config) { // Initialize Ontology services this.relationshipTypes = new RelationshipTypeService(httpClient); this.ontologyAxioms = new OntologyAxiomService(httpClient); + this.ontologies = new OntologyService(httpClient); + this.ontologyConcepts = new OntologyConceptService(httpClient); + this.ontologyProperties = new OntologyPropertyService(httpClient); + this.ontologyReleases = new OntologyReleaseService(httpClient); this.ontologyAi = new OntologyAiService(httpClient); this.ontologyBulk = new OntologyBulkService(httpClient); this.ontologyChangeSets = new OntologyChangeSetService(httpClient); @@ -506,6 +518,22 @@ public OntologyAxiomService ontologyAxioms() { return ontologyAxioms; } + public OntologyService ontologies() { + return ontologies; + } + + public OntologyReleaseService ontologyReleases() { + return ontologyReleases; + } + + public OntologyConceptService ontologyConcepts() { + return ontologyConcepts; + } + + public OntologyPropertyService ontologyProperties() { + return ontologyProperties; + } + public OntologyAiService ontologyAi() { return ontologyAi; } diff --git a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyConceptService.java b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyConceptService.java new file mode 100644 index 000000000000..de659c8cecd0 --- /dev/null +++ b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyConceptService.java @@ -0,0 +1,37 @@ +package org.openmetadata.sdk.services.ontology; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; +import org.openmetadata.schema.api.data.CreateOntologyConcept; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.sdk.network.HttpClient; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.services.EntityServiceBase; + +public final class OntologyConceptService extends EntityServiceBase { + public OntologyConceptService(final HttpClient httpClient) { + super(httpClient, "/v1/ontologyConcepts"); + } + + public OntologyConcept create(final CreateOntologyConcept request) { + return httpClient.execute(HttpMethod.POST, basePath, request, OntologyConcept.class); + } + + public OntologyConcept upsert(final CreateOntologyConcept request) { + return httpClient.execute(HttpMethod.PUT, basePath, request, OntologyConcept.class); + } + + @Override + protected void normalizePatchNodes( + final JsonNode original, final JsonNode updated, final OntologyConcept entity) { + for (final String field : List.of("superClasses", "subClasses")) { + removeField(original, field); + removeField(updated, field); + } + } + + @Override + protected Class getEntityClass() { + return OntologyConcept.class; + } +} diff --git a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyPropertyService.java b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyPropertyService.java new file mode 100644 index 000000000000..1c9589d0ddee --- /dev/null +++ b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyPropertyService.java @@ -0,0 +1,26 @@ +package org.openmetadata.sdk.services.ontology; + +import org.openmetadata.schema.api.data.CreateOntologyProperty; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.sdk.network.HttpClient; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.services.EntityServiceBase; + +public final class OntologyPropertyService extends EntityServiceBase { + public OntologyPropertyService(final HttpClient httpClient) { + super(httpClient, "/v1/ontologyProperties"); + } + + public OntologyProperty create(final CreateOntologyProperty request) { + return httpClient.execute(HttpMethod.POST, basePath, request, OntologyProperty.class); + } + + public OntologyProperty upsert(final CreateOntologyProperty request) { + return httpClient.execute(HttpMethod.PUT, basePath, request, OntologyProperty.class); + } + + @Override + protected Class getEntityClass() { + return OntologyProperty.class; + } +} diff --git a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyReleaseService.java b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyReleaseService.java new file mode 100644 index 000000000000..50da561fc0a7 --- /dev/null +++ b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyReleaseService.java @@ -0,0 +1,46 @@ +package org.openmetadata.sdk.services.ontology; + +import java.util.UUID; +import org.openmetadata.schema.api.data.OntologyReleaseDiff; +import org.openmetadata.schema.api.data.OntologyReleaseStructureReport; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.sdk.network.HttpClient; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.network.RequestOptions; + +/** Read-only access to sealed ontology content; publication uses the governed release lifecycle. */ +public final class OntologyReleaseService { + private static final String BASE_PATH = "/v1/ontologyReleases/"; + private final HttpClient httpClient; + + public OntologyReleaseService(final HttpClient httpClient) { + this.httpClient = httpClient; + } + + public OntologyRelease get(final UUID id) { + return httpClient.execute(HttpMethod.GET, BASE_PATH + id, null, OntologyRelease.class); + } + + public OntologyRelease getVersion(final UUID id, final Double version) { + return httpClient.execute( + HttpMethod.GET, BASE_PATH + id + "/versions/" + version, null, OntologyRelease.class); + } + + public OntologyReleaseStructureReport structure(final UUID id) { + return httpClient.execute( + HttpMethod.GET, BASE_PATH + id + "/structure", null, OntologyReleaseStructureReport.class); + } + + public OntologyReleaseDiff diff( + final UUID id, final UUID baseReleaseId, final int limit, final String after) { + final var options = RequestOptions.builder().queryParam("limit", Integer.toString(limit)); + if (baseReleaseId != null) { + options.queryParam("baseReleaseId", baseReleaseId.toString()); + } + if (after != null) { + options.queryParam("after", after); + } + return httpClient.execute( + HttpMethod.GET, BASE_PATH + id + "/diff", null, OntologyReleaseDiff.class, options.build()); + } +} diff --git a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyService.java b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyService.java new file mode 100644 index 000000000000..7c35fd54d4d7 --- /dev/null +++ b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyService.java @@ -0,0 +1,46 @@ +package org.openmetadata.sdk.services.ontology; + +import java.util.UUID; +import org.openmetadata.schema.api.data.ActivateOntologyRelease; +import org.openmetadata.schema.api.data.CaptureOntologyRelease; +import org.openmetadata.schema.api.data.CreateOntology; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.OntologyState; +import org.openmetadata.sdk.network.HttpClient; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.services.EntityServiceBase; + +public final class OntologyService extends EntityServiceBase { + public OntologyService(final HttpClient httpClient) { + super(httpClient, "/v1/ontologies"); + } + + public Ontology create(final CreateOntology request) { + return httpClient.execute(HttpMethod.POST, basePath, request, Ontology.class); + } + + public Ontology upsert(final CreateOntology request) { + return httpClient.execute(HttpMethod.PUT, basePath, request, Ontology.class); + } + + public OntologyState state(final UUID id) { + return httpClient.execute( + HttpMethod.GET, basePath + "/" + id + "/state", null, OntologyState.class); + } + + public OntologyRelease capture(final UUID id, final CaptureOntologyRelease request) { + return httpClient.execute( + HttpMethod.POST, basePath + "/" + id + "/releases", request, OntologyRelease.class); + } + + public OntologyState activate(final UUID id, final ActivateOntologyRelease request) { + return httpClient.execute( + HttpMethod.PUT, basePath + "/" + id + "/activation", request, OntologyState.class); + } + + @Override + protected Class getEntityClass() { + return Ontology.class; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java index 96d782d2e6af..06aae3ecb0ce 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java @@ -214,6 +214,10 @@ public final class Entity { public static final String GLOSSARY = "glossary"; public static final String GLOSSARY_TERM = "glossaryTerm"; public static final String RELATIONSHIP_TYPE = "relationshipType"; + public static final String ONTOLOGY = "ontology"; + public static final String ONTOLOGY_CONCEPT = "ontologyConcept"; + public static final String ONTOLOGY_PROPERTY = "ontologyProperty"; + public static final String ONTOLOGY_RELEASE = "ontologyRelease"; public static final String ONTOLOGY_AXIOM = "ontologyAxiom"; public static final String ONTOLOGY_CHANGE_SET = "ontologyChangeSet"; public static final String TAG = "tag"; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java index fb57f7bf3714..5dd7c011b15b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java @@ -125,6 +125,7 @@ import org.openmetadata.service.exception.OMErrorPageHandler; import org.openmetadata.service.fernet.Fernet; import org.openmetadata.service.governance.approval.ChangeRequestRecoveryScheduler; +import org.openmetadata.service.governance.workflows.OntologyReviewReconciler; import org.openmetadata.service.governance.workflows.WorkflowHandler; import org.openmetadata.service.jdbi3.BulkExecutor; import org.openmetadata.service.jdbi3.CollectionDAO; @@ -472,6 +473,9 @@ public void run(OpenMetadataApplicationConfig catalogConfig, Environment environ environment.lifecycle().manage(new ManagedShutdown()); JobHandlerRegistry registry = getJobHandlerRegistry(); + environment + .lifecycle() + .manage(new OntologyReviewReconciler(jdbi.onDemand(CollectionDAO.class))); environment .lifecycle() .manage(new GenericBackgroundWorker(jdbi.onDemand(JobDAO.class), registry)); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/rdf/RdfIndexApp.java b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/rdf/RdfIndexApp.java index cb194f7f8582..1a3cc49e0db6 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/rdf/RdfIndexApp.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/rdf/RdfIndexApp.java @@ -207,11 +207,17 @@ private void runHoldingLock(JobExecutionContext jobExecutionContext) { jobData.setRdfBuildDataset(null); jobData.setRdfRebuildId(null); rebuildLeaseFailure = null; + final Set requested = jobData.getEntities(); + final boolean includesOntologies = + requested == null + || requested.isEmpty() + || requested.contains(ALL) + || requested.contains(Entity.ONTOLOGY); jobData.setEntities(resolveEntityTypes(jobData.getEntities())); rebuildsEntireProjection = Boolean.TRUE.equals(jobData.getRecreateIndex()) && jobData.getEntities().containsAll(getAll()); - if (jobData.getEntities().isEmpty()) { + if (jobData.getEntities().isEmpty() && !includesOntologies) { throw new IllegalStateException( "No repository-backed entity types configured for RDF indexing"); } @@ -244,6 +250,8 @@ private void runHoldingLock(JobExecutionContext jobExecutionContext) { LOG.info("Clearing existing RDF data"); markIncompleteDuringInPlaceRebuild(); clearRdfData(); + } else if (includesOntologies) { + indexingRepository.rebuildOntologyGraphs(); } } @@ -553,6 +561,7 @@ private void prepareBuildDataset(RdfRepository indexingRepository, String target indexingRepository.clearAll(); indexingRepository.compactStorage(); indexingRepository.reloadOntologies(); + indexingRepository.rebuildOntologyGraphs(); LOG.info("Build dataset '{}' is empty and ontology-seeded", targetDataset); } @@ -679,6 +688,7 @@ private void clearRdfData() { // before indexing starts so SPARQL queries that depend on the ontology // (inference, federated, etc.) work after the wipe. rdf().reloadOntologies(); + rdf().rebuildOntologyGraphs(); } catch (Exception e) { LOG.error("Failed to clear RDF data", e); throw new RuntimeException("Failed to clear RDF data", e); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogGenericExceptionMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogGenericExceptionMapper.java index 2d7dcdfe5967..138c32abb398 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogGenericExceptionMapper.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogGenericExceptionMapper.java @@ -57,7 +57,12 @@ public Response toResponse(Throwable ex) { if (AgentSparqlTransport.isAgentSparqlRequest(uriInfo)) { return AgentSparqlTransport.errorResponse(ex); } - if (ex instanceof RuleValidationException) { + if (ex instanceof OntologyValidationException validation) { + return Response.status(BAD_REQUEST) + .type(APPLICATION_JSON_TYPE) + .entity(validation.getError()) + .build(); + } else if (ex instanceof RuleValidationException) { return getRuleViolationResponse(ex); } else if (ex instanceof BadRequestException || ex instanceof IllegalArgumentException) { return getResponse(BAD_REQUEST, ex.getMessage()); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/exception/OntologyValidationException.java b/openmetadata-service/src/main/java/org/openmetadata/service/exception/OntologyValidationException.java new file mode 100644 index 000000000000..cf4b72949c79 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/exception/OntologyValidationException.java @@ -0,0 +1,30 @@ +package org.openmetadata.service.exception; + +import java.util.List; +import java.util.stream.Collectors; +import org.openmetadata.schema.api.data.OntologyValidationError; +import org.openmetadata.schema.type.OntologyReleaseStructureIssue; + +public final class OntologyValidationException extends IllegalArgumentException { + private final OntologyValidationError error; + + public OntologyValidationException( + final int count, final List violations) { + super( + "Ontology validation failed: " + + violations.stream() + .limit(3) + .map(issue -> issue.getViolation().getMessage()) + .collect(Collectors.joining("; "))); + error = + new OntologyValidationError() + .withCode(400) + .withMessage(getMessage()) + .withViolationCount(count) + .withViolations(List.copyOf(violations)); + } + + public OntologyValidationError getError() { + return error; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/exception/TaskStateConflictException.java b/openmetadata-service/src/main/java/org/openmetadata/service/exception/TaskStateConflictException.java index 4ad7fcdcca96..be48c3802ca8 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/exception/TaskStateConflictException.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/exception/TaskStateConflictException.java @@ -17,14 +17,13 @@ import org.openmetadata.sdk.exception.WebServiceException; /** - * Thrown when a caller's task-resolve request loses a race against another concurrent - * resolve / a workflow instance that has already advanced past the target node. Maps to + * Thrown when task submission or resolution conflicts with an open review, a concurrent + * decision, or a workflow instance that has already advanced past the target node. Maps to * 409 CONFLICT — the caller's request was well-formed but the task's current state no * longer permits the requested transition. * - *

Distinct from {@code IllegalStateException} on purpose: only the specific - * resolve-race sites in {@link org.openmetadata.service.tasks.TaskWorkflowHandler} throw - * this. Other {@code IllegalStateException} instances remain server bugs and continue to + *

Distinct from {@code IllegalStateException} on purpose: only explicit + * lifecycle conflicts throw this. Other {@code IllegalStateException} instances remain server bugs and continue to * surface as 500 via {@link CatalogGenericExceptionMapper} — a global mapping would * hide those. */ diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/approval/ApprovalGate.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/approval/ApprovalGate.java index 0bee2751a717..8c122c877676 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/approval/ApprovalGate.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/approval/ApprovalGate.java @@ -33,6 +33,7 @@ import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.OntologyDefinition; import org.openmetadata.schema.governance.changeRequest.ChangeRequest; import org.openmetadata.schema.governance.changeRequest.ChangeRequestStatus; import org.openmetadata.schema.type.EntityReference; @@ -79,6 +80,15 @@ public final class ApprovalGate { private static final Set REPLACED_BY_IMPORT_UPDATE = Set.of(Entity.FIELD_TAGS, Entity.FIELD_OWNERS, Entity.FIELD_DOMAINS); + // Native definitions are working state; their immutable release is the approval unit. + private static final Set RELEASE_GOVERNED_TYPES = + Set.of( + Entity.ONTOLOGY, + Entity.ONTOLOGY_CONCEPT, + Entity.ONTOLOGY_PROPERTY, + Entity.ONTOLOGY_AXIOM, + Entity.ONTOLOGY_RELEASE); + private ApprovalGate() {} /** @@ -134,7 +144,8 @@ public static Optional admit( /** Whether an edit of {@code entityType} by {@code user} can be held at all. */ public static boolean mayHold(String entityType, String user) { - return !GovernanceApprovalRegistry.gatingRules(entityType).isEmpty() + return !RELEASE_GOVERNED_TYPES.contains(entityType) + && !GovernanceApprovalRegistry.gatingRules(entityType).isEmpty() && !isBotChange(user, null); } @@ -152,7 +163,7 @@ private static Optional evaluate( Edit edit, boolean record) { Optional staged = Optional.empty(); - if (original != null && original.getId() != null) { + if (original != null && original.getId() != null && !(original instanceof OntologyDefinition)) { staged = stageIfGated(original, updated, user, impersonatedBy, edit, record); } return staged; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyApprovalWorkflow.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyApprovalWorkflow.java new file mode 100644 index 000000000000..51c0a0b7e40e --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyApprovalWorkflow.java @@ -0,0 +1,228 @@ +package org.openmetadata.service.governance.workflows; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.TaskEntityStatus; +import org.openmetadata.schema.type.TaskEntityType; +import org.openmetadata.schema.type.TaskResolution; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.jdbi3.OntologyEntityRepository; +import org.openmetadata.service.jdbi3.OntologyReleaseRepository; +import org.openmetadata.service.jdbi3.TaskRepository; +import org.openmetadata.service.util.FreshReadScope; + +/** Binds ontology revisions to the existing approval-task lifecycle. */ +public final class OntologyApprovalWorkflow { + private static final Set ENTITY_TYPES = Set.of(Entity.ONTOLOGY_RELEASE); + + private OntologyApprovalWorkflow() {} + + public static boolean isReview(final Task task) { + return task.getOntologyReview() != null + || (task.getType() == TaskEntityType.RequestApproval + && task.getAbout() != null + && ENTITY_TYPES.contains(task.getAbout().getType())); + } + + public static void prepare(final Task task, final boolean update) { + if (!update + && task.getType() == TaskEntityType.RequestApproval + && task.getAbout() != null + && !Entity.ONTOLOGY_RELEASE.equals(task.getAbout().getType()) + && Entity.getEntityRepository(task.getAbout().getType()) + instanceof OntologyEntityRepository) { + throw new BadRequestException("Submit the whole ontology release for approval"); + } + if (!update && isReview(task)) { + try (FreshReadScope.Handle ignored = FreshReadScope.enter()) { + repository(task).prepareReview(task); + } + } + } + + public static void start(final Task task) { + if (isReview(task)) { + repository(task).startReview(task); + trackExecution(task.getId(), null); + } + } + + public static void validate(final Task task, final String user) { + if (isReview(task)) { + try (FreshReadScope.Handle ignored = FreshReadScope.enter()) { + repository(task).validateReview(task, user); + } + } + } + + public static void lockTarget(final Task task) { + repository(task).lockReviewTarget(task); + } + + public static void validateRuntime(final Task task) { + try (FreshReadScope.Handle ignored = FreshReadScope.enter()) { + repository(task).validateRuntimeReview(task); + } + } + + public static void validateCurrentReview(final Task task) { + repository(task).requireCurrentReview(task); + } + + public static void complete(final Task task, final String user) { + if (isReview(task)) { + repository(task).completeReview(task, user); + } + } + + public static void cancel(final Task task) { + if (isReview(task) + && TaskRepository.isTerminalStatus(task.getStatus()) + && task.getStatus() != TaskEntityStatus.Rejected) { + if (task.getAbout() != null) { + repository(task).cancelReview(task); + } + trackExecution(task.getId(), null); + } + } + + public static void recordDecision( + final Task task, + final OntologyWorkflowDecision decision, + final Map variables) { + decision.requireTarget(task); + final WorkflowHandler workflow = WorkflowHandler.getInstance(); + final WorkflowHandler.ApprovalTaskContext context = workflow.approvalTaskContext(task.getId()); + final List voters = new ArrayList<>(context.voters()); + voters.addAll(decision.voters()); + final Object previous = + workflow + .getRuntimeService() + .getVariable(context.processInstanceId(), OntologyWorkflowDecision.VARIABLE); + if (previous instanceof String json) { + final OntologyWorkflowDecision prior = + JsonUtils.readValue(json, OntologyWorkflowDecision.class); + prior.requireTarget(task); + voters.addAll(prior.voters()); + } + voters.stream().distinct().forEach(voter -> validate(task, voter)); + trackExecution(task.getId(), context.processInstanceId()); + variables.put( + OntologyWorkflowDecision.VARIABLE, + JsonUtils.pojoToJson( + new OntologyWorkflowDecision( + task.getId(), + task.getOntologyReview(), + decision.resolution(), + decision.user(), + voters.stream().distinct().toList(), + decision.transition(), + decision.comment()))); + } + + public static Task completeDecision(final Task task, final OntologyWorkflowDecision decision) { + final TaskRepository tasks = (TaskRepository) Entity.getEntityRepository(Entity.TASK); + return tasks.executeInTransaction( + () -> { + try (FreshReadScope.Handle ignored = FreshReadScope.enter()) { + lockTarget(task); + final Task current = + tasks.get(null, task.getId(), tasks.getFields("*"), Include.ALL, false); + decision.requireTarget(current); + return TaskRepository.isTerminalStatus(current.getStatus()) + || current.getResolution() != null + ? current + : applyDecision(tasks, current, decision); + } + }); + } + + private static Task applyDecision( + final TaskRepository tasks, final Task task, final OntologyWorkflowDecision decision) { + decision.voters().forEach(voter -> validate(task, voter)); + final TaskResolution resolution = + new TaskResolution() + .withType(decision.resolution()) + .withResolvedBy( + Entity.getEntityReferenceByName(Entity.USER, decision.user(), Include.NON_DELETED)) + .withResolvedAt(System.currentTimeMillis()) + .withComment(decision.comment()); + final Task resolved = + tasks.resolveTask(task, resolution, decision.transition(), decision.user()); + complete(resolved, decision.user()); + repository(task).recordDecision(resolved, decision); + Entity.getCollectionDAO() + .entityExtensionDAO() + .delete(task.getId(), OntologyReviewReconciler.EXTENSION); + return resolved; + } + + public static void trackExecution(final UUID taskId, final String processId) { + final var extensions = Entity.getCollectionDAO().entityExtensionDAO(); + if (processId != null + || extensions.getExtension(taskId, OntologyReviewReconciler.EXTENSION) == null) { + extensions.insert( + taskId, + OntologyReviewReconciler.EXTENSION, + "ontologyReviewExecution", + JsonUtils.pojoToJson(new OntologyReviewReconciler.Execution(processId))); + } + } + + public static void requireUnchangedTarget(final Task original, final Task updated) { + if (!Objects.equals(original.getOntologyReview(), updated.getOntologyReview()) + || (isReview(original) + && (!Objects.equals(original.getType(), updated.getType()) + || !sameReference(original.getAbout(), updated.getAbout()) + || !sameReference(original.getCreatedBy(), updated.getCreatedBy()) + || !Objects.equals( + original.getWorkflowDefinitionId(), updated.getWorkflowDefinitionId())))) { + throw new BadRequestException( + "An ontology task's review target and workflow cannot be changed"); + } + if (isReview(original) + && (TaskRepository.isTerminalStatus(original.getStatus()) + || original.getResolution() != null) + && (original.getStatus() != updated.getStatus() + || !Objects.equals(original.getResolution(), updated.getResolution()))) { + throw new BadRequestException( + "A finished ontology review cannot be changed; submit a new approval task"); + } + } + + public static void requireDeletable(final Task task) { + if (isReview(task) + && ((!TaskRepository.isTerminalStatus(task.getStatus()) && task.getResolution() == null) + || Entity.getCollectionDAO() + .entityExtensionDAO() + .getExtension(task.getId(), OntologyReviewReconciler.EXTENSION) + != null)) { + throw new BadRequestException( + "Close the ontology review and wait for workflow cleanup before deleting its task"); + } + } + + private static boolean sameReference(final EntityReference first, final EntityReference second) { + return first == null + ? second == null + : second != null + && Objects.equals(first.getId(), second.getId()) + && Objects.equals(first.getType(), second.getType()); + } + + private static OntologyReleaseRepository repository(final Task task) { + if (task.getAbout() == null || !ENTITY_TYPES.contains(task.getAbout().getType())) { + throw new BadRequestException("Invalid ontology approval target"); + } + return (OntologyReleaseRepository) Entity.getEntityRepository(task.getAbout().getType()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyReviewReconciler.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyReviewReconciler.java new file mode 100644 index 000000000000..472c1cae4f00 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyReviewReconciler.java @@ -0,0 +1,234 @@ +package org.openmetadata.service.governance.workflows; + +import static org.openmetadata.service.governance.workflows.WorkflowEventConsumer.GOVERNANCE_BOT; + +import io.dropwizard.lifecycle.Managed; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.flowable.engine.history.HistoricProcessInstance; +import org.flowable.engine.runtime.ProcessInstance; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.governance.workflows.WorkflowDefinition; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.TaskResolution; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.exception.EntityNotFoundException; +import org.openmetadata.service.exception.WorkflowInstanceConflictException; +import org.openmetadata.service.governance.workflows.WorkflowHandler.TaskResolutionOutcome; +import org.openmetadata.service.governance.workflows.WorkflowInstanceTerminator.TerminationRequest; +import org.openmetadata.service.jdbi3.CollectionDAO; +import org.openmetadata.service.jdbi3.TaskRepository; +import org.openmetadata.service.jdbi3.WorkflowInstanceRepository; +import org.openmetadata.service.security.AuthorizationException; +import org.openmetadata.service.tasks.TaskWorkflowLifecycleResolver.WorkflowStartVariables; +import org.openmetadata.service.util.FreshReadScope; + +/** Replays committed workflow decisions; the task and definition still own their lifecycle. */ +@Slf4j +public final class OntologyReviewReconciler implements Managed { + public static final String EXTENSION = "ontology.reviewExecution"; + private static final int PAGE_SIZE = 100; + private static final Duration START_TIMEOUT = Duration.ofMinutes(5); + private final CollectionDAO dao; + private ScheduledExecutorService scheduler; + private String after = ""; + + public record Execution(String processInstanceId) {} + + public OntologyReviewReconciler(final CollectionDAO dao) { + this.dao = dao; + } + + @Override + public void start() { + scheduler = + Executors.newSingleThreadScheduledExecutor( + Thread.ofPlatform().daemon().name("ontology-review-reconciliation").factory()); + scheduler.scheduleWithFixedDelay(this::runSafely, 10, 30, TimeUnit.SECONDS); + } + + @Override + public void stop() { + if (scheduler != null) { + scheduler.shutdownNow(); + } + } + + private void runSafely() { + try { + runOnce(); + } catch (RuntimeException exception) { + LOG.warn("Unable to read pending ontology reviews", exception); + } + } + + public void runOnce() { + if (!WorkflowHandler.isInitialized()) { + return; + } + final List ids = dao.entityExtensionDAO().listExtensionIds(EXTENSION, after, PAGE_SIZE); + for (final String id : ids) { + try { + reconcile(UUID.fromString(id)); + } catch (RuntimeException exception) { + LOG.warn("Ontology review {} remains pending reconciliation", id, exception); + } + } + after = ids.size() == PAGE_SIZE ? ids.getLast() : ""; + } + + public void reconcile(final UUID taskId) { + final String json = dao.entityExtensionDAO().getExtension(taskId, EXTENSION); + if (json == null) { + return; + } + try (FreshReadScope.Handle ignored = FreshReadScope.enter()) { + final TaskRepository tasks = (TaskRepository) Entity.getEntityRepository(Entity.TASK); + final Task stored = tasks.findCommittedTask(taskId); + if (stored == null) { + cancelExecution(taskId); + return; + } + final Task task = tasks.get(null, taskId, tasks.getFields("*"), Include.ALL, false); + if (TaskRepository.isTerminalStatus(task.getStatus()) + || task.getResolution() != null + || Boolean.TRUE.equals(task.getDeleted())) { + cancelExecution(taskId); + } else { + try { + OntologyApprovalWorkflow.validateCurrentReview(task); + reconcileDecision(tasks, task, JsonUtils.readValue(json, Execution.class)); + } catch (BadRequestException | AuthorizationException | EntityNotFoundException exception) { + LOG.info( + "Ontology review {} awaits a valid review context: {}", + taskId, + exception.getMessage()); + } + } + } + } + + private void reconcileDecision( + final TaskRepository tasks, final Task task, final Execution execution) { + final String processId = + execution.processInstanceId() == null ? findExecution(task) : execution.processInstanceId(); + if (processId == null) { + if (failedToStart(task)) { + cancelObsoleteReview(tasks, task, "The ontology approval workflow failed to start"); + } + return; + } + final WorkflowHandler workflow = WorkflowHandler.getInstance(); + final TaskResolutionOutcome outcome = workflow.taskResolutionOutcome(processId); + if (outcome == TaskResolutionOutcome.COMPLETED) { + final OntologyWorkflowDecision decision = + workflow.completedOntologyDecision(processId).orElse(null); + if (decision != null) { + OntologyApprovalWorkflow.completeDecision(task, decision); + } else { + cancelObsoleteReview( + tasks, task, "The workflow completed without an ontology approval decision"); + } + } else if (outcome == TaskResolutionOutcome.PROCESS_FAILED) { + cancelObsoleteReview( + tasks, task, "The ontology approval workflow did not complete successfully"); + } + } + + private static boolean failedToStart(final Task task) { + return TaskRepository.WORKFLOW_START_FAILED_STAGE_ID.equals(task.getWorkflowStageId()) + || (task.getCreatedAt() < System.currentTimeMillis() - START_TIMEOUT.toMillis() + && WorkflowHandler.getInstance() + .getRuntimeService() + .createProcessInstanceQuery() + .variableValueEquals( + WorkflowStartVariables.TASK_ENTITY_ID, task.getId().toString()) + .count() + == 0); + } + + private String findExecution(final Task task) { + final WorkflowDefinition definition = + Entity.getEntity( + Entity.WORKFLOW_DEFINITION, task.getWorkflowDefinitionId(), "", Include.ALL); + final List executions = + WorkflowHandler.getInstance() + .getProcessEngineConfiguration() + .getHistoryService() + .createHistoricProcessInstanceQuery() + .processDefinitionKey(definition.getFullyQualifiedName()) + .variableValueEquals(WorkflowStartVariables.TASK_ENTITY_ID, task.getId().toString()) + .listPage(0, 2); + if (executions.size() > 1) { + throw new BadRequestException("An ontology review must have exactly one workflow execution"); + } + final String processId = executions.isEmpty() ? null : executions.getFirst().getId(); + if (processId != null) { + OntologyApprovalWorkflow.trackExecution(task.getId(), processId); + } + return processId; + } + + private void cancelObsoleteReview( + final TaskRepository tasks, final Task task, final String reason) { + tasks.executeInTransaction( + () -> { + try (FreshReadScope.Handle ignored = FreshReadScope.enter()) { + dao.workflowDefinitionDAO() + .findJsonByIdForUpdate(task.getWorkflowDefinitionId(), Include.ALL); + final Task current = + tasks.get(null, task.getId(), tasks.getFields("*"), Include.ALL, false); + return TaskRepository.isTerminalStatus(current.getStatus()) + || current.getResolution() != null + ? current + : tasks.resolveTask( + current, + new TaskResolution() + .withType(TaskResolutionType.Cancelled) + .withResolvedAt(System.currentTimeMillis()) + .withComment(reason), + GOVERNANCE_BOT); + } + }); + cancelExecution(task.getId()); + } + + private void cancelExecution(final UUID taskId) { + final WorkflowHandler workflow = WorkflowHandler.getInstance(); + final List roots = + workflow + .getRuntimeService() + .createProcessInstanceQuery() + .variableValueEquals(WorkflowStartVariables.TASK_ENTITY_ID, taskId.toString()) + .excludeSubprocesses(true) + .listPage(0, PAGE_SIZE); + roots.forEach(this::terminateExecution); + if (roots.isEmpty()) { + dao.entityExtensionDAO().delete(taskId, EXTENSION); + } + } + + private void terminateExecution(final ProcessInstance root) { + try { + final WorkflowInstanceRepository instances = + (WorkflowInstanceRepository) + Entity.getEntityTimeSeriesRepository(Entity.WORKFLOW_INSTANCE); + WorkflowInstanceTerminator.forCurrentEngine(instances) + .terminate( + new TerminationRequest( + UUID.fromString(root.getBusinessKey()), + "Ontology review is no longer active", + GOVERNANCE_BOT)); + } catch (WorkflowInstanceConflictException exception) { + LOG.debug("Workflow {} cleanup will retry: {}", root.getId(), exception.getMessage()); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyWorkflowDecision.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyWorkflowDecision.java new file mode 100644 index 000000000000..be5937d72ba7 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyWorkflowDecision.java @@ -0,0 +1,43 @@ +package org.openmetadata.service.governance.workflows; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.OntologyReviewTarget; +import org.openmetadata.schema.type.TaskAvailableTransition; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.exception.BadRequestException; + +/** A decision committed with the Flowable task completion, recoverable after an OM restart. */ +public record OntologyWorkflowDecision( + UUID taskId, + OntologyReviewTarget target, + TaskResolutionType resolution, + String user, + List voters, + TaskAvailableTransition transition, + String comment) { + public static final String VARIABLE = "ontologyReviewDecision"; + + public OntologyWorkflowDecision { + if (taskId == null + || target == null + || (resolution != TaskResolutionType.Approved && resolution != TaskResolutionType.Rejected) + || user == null + || voters == null + || !voters.contains(user)) { + throw new BadRequestException("An ontology decision requires an explicit reviewer decision"); + } + target = JsonUtils.deepCopy(target, OntologyReviewTarget.class); + voters = List.copyOf(voters); + } + + public void requireTarget(final Task task) { + if (!Objects.equals(taskId, task.getId()) + || !Objects.equals(target, task.getOntologyReview())) { + throw new BadRequestException("The workflow decision belongs to another ontology review"); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowHandler.java index 127d091de375..44aeb4db8961 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/WorkflowHandler.java @@ -1,6 +1,8 @@ package org.openmetadata.service.governance.workflows; import static org.openmetadata.service.governance.workflows.Workflow.APPROVE_CONDITION; +import static org.openmetadata.service.governance.workflows.Workflow.EXCEPTION_VARIABLE; +import static org.openmetadata.service.governance.workflows.Workflow.FAILURE_VARIABLE; import static org.openmetadata.service.governance.workflows.Workflow.GLOBAL_NAMESPACE; import static org.openmetadata.service.governance.workflows.Workflow.LEGACY_APPROVE_CONDITION; import static org.openmetadata.service.governance.workflows.Workflow.LEGACY_REJECT_CONDITION; @@ -70,6 +72,7 @@ import org.openmetadata.service.clients.pipeline.PipelineServiceClientFactory; import org.openmetadata.service.exception.UnhandledServerException; import org.openmetadata.service.governance.workflows.elements.triggers.PeriodicBatchEntityTrigger; +import org.openmetadata.service.governance.workflows.flowable.MainWorkflow; import org.openmetadata.service.governance.workflows.flowable.sql.SqlMapper; import org.openmetadata.service.governance.workflows.flowable.sql.UnlockExecutionSql; import org.openmetadata.service.governance.workflows.flowable.sql.UnlockJobSql; @@ -1042,18 +1045,58 @@ public boolean resolveTask(UUID taskId) { } public boolean resolveTask(UUID customTaskId, Map variables) { - return resolveTaskInternal(customTaskId, variables); + return resolveTaskOutcome(customTaskId, variables, false) + != TaskResolutionOutcome.RESOLUTION_FAILED; + } + + public enum TaskResolutionOutcome { + COMPLETED, + STAGE_ADVANCED, + WAITING_FOR_VOTES, + RESOLUTION_FAILED, + PROCESS_FAILED + } + + public record ApprovalTaskContext( + String processInstanceId, String nodeName, List voters) {} + + public ApprovalTaskContext approvalTaskContext(UUID customTaskId) { + final Task task = getTaskFromCustomTaskId(customTaskId); + if (task == null) { + throw new IllegalStateException("The approval workflow task is unavailable"); + } + final List voters = new ArrayList<>(); + final TaskService taskService = processEngine.getTaskService(); + for (final String variable : List.of("approversList", "rejectersList")) { + final Object value = taskService.getVariable(task.getId(), variable); + if (value instanceof List recordedVoters) { + recordedVoters.forEach(voter -> voters.add((String) voter)); + } else if (value != null) { + throw new IllegalStateException("Invalid workflow approval vote history"); + } + } + return new ApprovalTaskContext( + task.getProcessInstanceId(), + getParentActivityId(task.getExecutionId()), + List.copyOf(voters)); + } + + public TaskResolutionOutcome resolveTaskOutcome( + UUID customTaskId, Map variables) { + return resolveTaskOutcome(customTaskId, variables, true); } - private boolean resolveTaskInternal(UUID customTaskId, Map variables) { + private TaskResolutionOutcome resolveTaskOutcome( + UUID customTaskId, Map variables, boolean inspectProcess) { // Completing a user task continues the workflow inline, re-running the attribute gates that // decide the entity's next status — same freshness requirement as triggerWithSignal. try (FreshReadScope.Handle ignored = FreshReadScope.enter()) { - return resolveTaskWithFreshReads(customTaskId, variables); + return resolveTaskWithFreshReads(customTaskId, variables, inspectProcess); } } - private boolean resolveTaskWithFreshReads(UUID customTaskId, Map variables) { + private TaskResolutionOutcome resolveTaskWithFreshReads( + UUID customTaskId, Map variables, boolean inspectProcess) { TaskService taskService = processEngine.getTaskService(); LOG.debug("[WorkflowTask] RESOLVE: customTaskId='{}' variables={}", customTaskId, variables); // Admission control: bound how many resolutions touch Flowable at once so an approval burst @@ -1065,7 +1108,7 @@ private boolean resolveTaskWithFreshReads(UUID customTaskId, Map Thread.currentThread().interrupt(); LOG.error( "[WorkflowTask] Interrupted while awaiting resolution permit for '{}'", customTaskId); - return false; + return TaskResolutionOutcome.RESOLUTION_FAILED; } try { Optional oTask = Optional.ofNullable(getTaskFromCustomTaskId(customTaskId)); @@ -1086,7 +1129,17 @@ private boolean resolveTaskWithFreshReads(UUID customTaskId, Map || (rejectionThreshold != null && rejectionThreshold > 1)) { // This is a multi-reviewer approval task boolean taskCompleted = - handleMultiApproval(task, variables, approvalThreshold, rejectionThreshold); + DeadlockRetry.execute( + () -> + processEngine + .getManagementService() + .executeCommand( + context -> + handleMultiApproval( + task, + new HashMap<>(variables), + approvalThreshold, + rejectionThreshold))); if (taskCompleted) { LOG.debug( "[WorkflowTask] SUCCESS: Multi-approval task '{}' completed with threshold met", @@ -1096,6 +1149,7 @@ private boolean resolveTaskWithFreshReads(UUID customTaskId, Map "[WorkflowTask] SUCCESS: Multi-approval task '{}' recorded vote, waiting for more votes", customTaskId); removeTaskFromVoterFeedForTaskEntity(task, customTaskId, variables); + return TaskResolutionOutcome.WAITING_FOR_VOTES; } } else { // Single approval - original behavior @@ -1115,26 +1169,79 @@ private boolean resolveTaskWithFreshReads(UUID customTaskId, Map }); LOG.debug("[WorkflowTask] SUCCESS: Task '{}' resolved", customTaskId); } - return true; + return inspectProcess + ? taskResolutionOutcome(task.getProcessInstanceId()) + : TaskResolutionOutcome.COMPLETED; } else { LOG.warn("[WorkflowTask] NOT_FOUND: No Flowable task for customTaskId='{}'", customTaskId); - return false; + return TaskResolutionOutcome.RESOLUTION_FAILED; } } catch (FlowableObjectNotFoundException ex) { LOG.error( "[WorkflowTask] ERROR: Flowable task not found for customTaskId='{}': {}", customTaskId, ex.getMessage()); - return false; + return TaskResolutionOutcome.RESOLUTION_FAILED; } catch (Exception e) { LOG.error( "[WorkflowTask] ERROR: Failed to resolve task '{}': {}", customTaskId, e.getMessage(), e); - return false; + return TaskResolutionOutcome.RESOLUTION_FAILED; } finally { taskResolutionPermits.release(); } } + public TaskResolutionOutcome taskResolutionOutcome(final String processInstanceId) { + if (processEngine + .getRuntimeService() + .createProcessInstanceQuery() + .processInstanceId(processInstanceId) + .count() + != 0) { + return TaskResolutionOutcome.STAGE_ADVANCED; + } + final HistoricProcessInstance completed = + processEngine + .getHistoryService() + .createHistoricProcessInstanceQuery() + .processInstanceId(processInstanceId) + .includeProcessVariables() + .singleResult(); + return completed != null + && completed.getEndTime() != null + && completed.getDeleteReason() == null + && !MainWorkflow.ERROR_END_EVENT_ID.equals(completed.getEndActivityId()) + && !hasWorkflowFailure(completed.getProcessVariables()) + ? TaskResolutionOutcome.COMPLETED + : TaskResolutionOutcome.PROCESS_FAILED; + } + + public Optional completedOntologyDecision( + final String processInstanceId) { + if (taskResolutionOutcome(processInstanceId) != TaskResolutionOutcome.COMPLETED) { + return Optional.empty(); + } + final HistoricProcessInstance completed = + processEngine + .getHistoryService() + .createHistoricProcessInstanceQuery() + .processInstanceId(processInstanceId) + .includeProcessVariables() + .singleResult(); + final Object receipt = completed.getProcessVariables().get(OntologyWorkflowDecision.VARIABLE); + return receipt instanceof String json + ? Optional.of(JsonUtils.readValue(json, OntologyWorkflowDecision.class)) + : Optional.empty(); + } + + private static boolean hasWorkflowFailure(final Map variables) { + return Boolean.TRUE.equals(variables.get(FAILURE_VARIABLE)) + || Boolean.TRUE.equals( + variables.get(getNamespacedVariableName(GLOBAL_NAMESPACE, FAILURE_VARIABLE))) + || variables.get(EXCEPTION_VARIABLE) != null + || variables.get(getNamespacedVariableName(GLOBAL_NAMESPACE, EXCEPTION_VARIABLE)) != null; + } + private String extractCurrentUser(Map variables) { if (variables == null || variables.isEmpty()) { return null; @@ -1328,6 +1435,23 @@ private boolean handleMultiApproval( taskService.setVariable(task.getId(), "approversList", approversList); taskService.setVariable(task.getId(), "rejectersList", rejectersList); + if (variables.get(OntologyWorkflowDecision.VARIABLE) instanceof String json) { + OntologyWorkflowDecision decision = JsonUtils.readValue(json, OntologyWorkflowDecision.class); + List voters = new ArrayList<>(decision.voters()); + voters.addAll(approversList); + voters.addAll(rejectersList); + variables.put( + OntologyWorkflowDecision.VARIABLE, + JsonUtils.pojoToJson( + new OntologyWorkflowDecision( + decision.taskId(), + decision.target(), + decision.resolution(), + decision.user(), + voters.stream().distinct().toList(), + decision.transition(), + decision.comment()))); + } int approvalCount = approversList.size(); int rejectionCount = rejectersList.size(); @@ -1351,7 +1475,7 @@ private boolean handleMultiApproval( Object rejectValue = resolveMultiApprovalResult(task.getProcessDefinitionId(), nodeName, false, Boolean.FALSE); variables.put(resultVariable, rejectValue); - DeadlockRetry.run(() -> taskService.complete(task.getId(), variables)); + taskService.complete(task.getId(), variables); return true; } @@ -1364,7 +1488,7 @@ private boolean handleMultiApproval( Object approveValue = resolveMultiApprovalResult(task.getProcessDefinitionId(), nodeName, true, Boolean.TRUE); variables.put(resultVariable, approveValue); - DeadlockRetry.run(() -> taskService.complete(task.getId(), variables)); + taskService.complete(task.getId(), variables); return true; } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/flowable/MainWorkflow.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/flowable/MainWorkflow.java index 2420946432c5..273990879f04 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/flowable/MainWorkflow.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/flowable/MainWorkflow.java @@ -31,6 +31,7 @@ @Slf4j @Getter public class MainWorkflow { + public static final String ERROR_END_EVENT_ID = "Error"; private final BpmnModel model; private final String workflowName; private final List runtimeExceptionBoundaryEvents = new ArrayList<>(); @@ -87,7 +88,7 @@ public MainWorkflow(WorkflowDefinition workflowDefinition) { } private void configureRuntimeExceptionFlow(Process process) { - EndEvent errorEndEvent = new EndEvent("Error"); + EndEvent errorEndEvent = new EndEvent(ERROR_END_EVENT_ID); process.addFlowElement(errorEndEvent.getEndEvent()); for (BoundaryEvent event : runtimeExceptionBoundaryEvents) { process.addFlowElement(new SequenceFlow(event.getId(), errorEndEvent.getEndEvent().getId())); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java index e6715562666f..aebc71d67e11 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContextMemoryRepository.java @@ -70,6 +70,12 @@ public class ContextMemoryRepository extends EntityRepository { public static final String FIELD_PRIMARY_ENTITY = "primaryEntity"; public static final String FIELD_RELATED_ENTITIES = "relatedEntities"; static final String FIELD_DERIVED_ENTITIES = "derivedEntities"; + private static final Set DERIVED_ENTITY_TYPES = + Set.of( + Entity.GLOSSARY_TERM, + Entity.ONTOLOGY_CONCEPT, + Entity.ONTOLOGY_PROPERTY, + Entity.ONTOLOGY_AXIOM); public static final String FIELD_SOURCE_FILE = "sourceFile"; public static final String FIELD_SOURCE_ENTITY = "sourceEntity"; public static final String FIELD_ROOT_MEMORY = "rootMemory"; @@ -425,8 +431,9 @@ private List getRelatedEntities(ContextMemory entity) { } private List getDerivedEntities(ContextMemory entity) { - return findFrom( - entity.getId(), Entity.CONTEXT_MEMORY, Relationship.DERIVED_FROM, Entity.GLOSSARY_TERM); + return findFrom(entity.getId(), Entity.CONTEXT_MEMORY, Relationship.DERIVED_FROM, null).stream() + .filter(reference -> DERIVED_ENTITY_TYPES.contains(reference.getType())) + .toList(); } private void fetchAndSetDerivedEntities(List entities, Fields fields) { @@ -443,7 +450,7 @@ private void fetchAndSetDerivedEntities(List entities, Fields fie Map refById = resolveReferencesByType(records); Map> derivedById = new HashMap<>(); for (CollectionDAO.EntityRelationshipObject record : records) { - if (!Entity.GLOSSARY_TERM.equals(record.getFromEntity())) { + if (!DERIVED_ENTITY_TYPES.contains(record.getFromEntity())) { continue; } EntityReference ref = refById.get(record.getFromId()); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CoreRelationshipDAOs.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CoreRelationshipDAOs.java index 478c19b32020..1be417a90ced 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CoreRelationshipDAOs.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CoreRelationshipDAOs.java @@ -142,6 +142,11 @@ void incrementRevisions( @SqlQuery("SELECT json FROM entity_extension WHERE id = :id AND extension = :extension") String getExtension(@BindUUID("id") UUID id, @Bind("extension") String extension); + @SqlQuery( + "SELECT id FROM entity_extension WHERE extension = :extension AND id > :after ORDER BY id LIMIT :limit") + List listExtensionIds( + @Bind("extension") String extension, @Bind("after") String after, @Bind("limit") int limit); + @SqlQuery( "SELECT extension FROM entity_extension WHERE id = :id AND extension " + "LIKE CONCAT (:extensionPrefix, '.%')") diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index ac77cd996406..53b6d8102342 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -692,6 +692,12 @@ public static void setLockManager(HierarchicalLockManager manager) { lockManager = manager; } + protected final void checkModificationsAllowed(final List entities) { + if (lockManager != null) { + lockManager.checkModificationsAllowed(entities); + } + } + public boolean isSupportsOwners() { return supportsOwners; } @@ -4399,9 +4405,7 @@ public List createManyEntitiesForImport(List entities, String impersonated } // 1. Batch lock manager check - if (lockManager != null) { - lockManager.checkModificationsAllowed(entities); - } + checkModificationsAllowed(entities); // 2. Set impersonatedBy and the creation audit for each entity for (T entity : entities) { @@ -4415,6 +4419,7 @@ public List createManyEntitiesForImport(List entities, String impersonated // the // handle is held). flushInOneTransaction( + entities, () -> { storeEntities(entities); storeExtensions(entities); @@ -4440,9 +4445,7 @@ public List updateManyEntitiesForImport( } // 1. Batch lock manager check - if (lockManager != null) { - lockManager.checkModificationsAllowed(updates); - } + checkModificationsAllowed(updates); List updatedEntities = new ArrayList<>(); for (int i = 0; i < originals.size(); i++) { @@ -4698,6 +4701,19 @@ private PutResponse putAndStore( T updated, boolean requireCurrentVersion, Set patchedFields) { + Supplier> write = + () -> applyPut(uriInfo, original, updated, requireCurrentVersion, patchedFields); + return Boolean.TRUE.equals(original.getDeleted()) + ? executeEntityWrite(List.of(original), write) + : write.get(); + } + + private PutResponse applyPut( + UriInfo uriInfo, + T original, + T updated, + boolean requireCurrentVersion, + Set patchedFields) { // If the entity state is soft-deleted, recursively undelete the entity and it's children if (Boolean.TRUE.equals(original.getDeleted())) { try (var ignored = phase("putRestoreEntity")) { @@ -5425,6 +5441,12 @@ public ResultList listFromSearchWithOffset( @Transaction private DeleteResponse delete( String deletedBy, T original, boolean recursive, boolean hardDelete) { + return executeEntityWrite( + List.of(original), () -> deleteContents(deletedBy, original, recursive, hardDelete)); + } + + private DeleteResponse deleteContents( + String deletedBy, T original, boolean recursive, boolean hardDelete) { checkSystemEntityDeletion(original); try (DeleteLifecycle ignored = beginDeleteLifecycle(original, deletedBy)) { setFieldsForDelete(original); @@ -5643,7 +5665,8 @@ protected final void cleanup(T entityInterface) { */ protected final void cleanup(String deletedBy, T entityInterface) { AtomicReference> holders = new AtomicReference<>(Set.of()); - flushInOneTransaction(() -> holders.set(cleanupFlushBody(deletedBy, entityInterface))); + flushInOneTransaction( + List.of(entityInterface), () -> holders.set(cleanupFlushBody(deletedBy, entityInterface))); removeCustomPropertyReferencesFromSearch(holders.get(), List.of(entityInterface.getId())); // Flowable commits on its own connection, so cancel only once the owning entity transaction // has committed: a rolled-back delete must not leave a live entity without its workflow. An @@ -5907,7 +5930,7 @@ protected T createNewEntity(T entity) { private void createNewEntityFlush(T entity) { assignInitialEntityStatus(entity); - flushInOneTransaction(() -> createNewEntityFlushBody(entity)); + flushInOneTransaction(List.of(entity), () -> createNewEntityFlushBody(entity)); try (var ignored = phase("createSetInheritedFields")) { setInheritedFields(entity, new Fields(allowedFields)); } @@ -5947,6 +5970,20 @@ protected void flushInOneTransaction(Runnable flushBody) { } } + /** Adds entity-specific locking around a complete mutation, including cascades and imports. */ + protected R executeEntityWrite(List entities, Supplier work) { + return work.get(); + } + + protected void flushInOneTransaction(List entities, Runnable work) { + executeEntityWrite( + entities, + () -> { + flushInOneTransaction(work); + return null; + }); + } + /** * Runs multi-repository work through the retained SQL-object root so every child DAO joins the * callback handle. Opening this boundary directly on {@link Entity#getJdbi()} does not bind the @@ -6209,7 +6246,7 @@ private void createManyEntitiesFlush(List entities) { for (int start = 0; start < entities.size(); start += BULK_CREATE_TXN_CHUNK_SIZE) { int end = Math.min(start + BULK_CREATE_TXN_CHUNK_SIZE, entities.size()); List chunk = entities.subList(start, end); - flushInOneTransaction(() -> createManyEntitiesFlushBody(chunk)); + flushInOneTransaction(chunk, () -> createManyEntitiesFlushBody(chunk)); } try (var ignored = phase("setInheritedFields")) { setInheritedFields(entities, new Fields(allowedFields)); @@ -7583,7 +7620,11 @@ private PutResponse restoreEntityInternal(String updatedBy, UUID id) { // a clean 404 instead of running children / hooks against a non-existent id and // potentially surfacing a 500 from a hook side-effect. T restoredEntity = find(id, ALL); + return executeEntityWrite( + List.of(restoredEntity), () -> restoreLoadedEntity(updatedBy, id, restoredEntity)); + } + private PutResponse restoreLoadedEntity(String updatedBy, UUID id, T restoredEntity) { // If an entity being restored contains other **deleted** children entities, restore them restoreChildren(id, updatedBy); @@ -7690,6 +7731,15 @@ public final void bulkRestoreSubtree(List ids, String updatedBy) { if (entities.isEmpty()) { return; } + executeEntityWrite( + entities, + () -> { + restoreSubtreeContents(entities, updatedBy); + return null; + }); + } + + private void restoreSubtreeContents(List entities, String updatedBy) { dispatchToContainedChildren( entities, "bulkRestoreFindChildren", @@ -7870,6 +7920,15 @@ public final void bulkSoftDeleteSubtree(List ids, String updatedBy) { } List entities = allEntities.stream().filter(e -> !Boolean.TRUE.equals(e.getDeleted())).toList(); + executeEntityWrite( + allEntities, + () -> { + softDeleteSubtreeContents(allEntities, entities, updatedBy); + return null; + }); + } + + private void softDeleteSubtreeContents(List allEntities, List entities, String updatedBy) { for (T entity : entities) { checkSystemEntityDeletion(entity); } @@ -7988,6 +8047,15 @@ private void bulkHardDeleteSubtreeChunk(List ids, String updatedBy) { if (entities.isEmpty()) { return; } + executeEntityWrite( + entities, + () -> { + hardDeleteSubtreeContents(entities, updatedBy); + return null; + }); + } + + private void hardDeleteSubtreeContents(List entities, String updatedBy) { Runnable exitHardDeleteCascade = enterBulkHardDeleteCascade(entities); // Restored rather than cleared: this method recurses through dispatchToContainedChildren, so // an inner frame must not un-flag the outer cascade on its way out. @@ -10188,10 +10256,11 @@ private void flushUpdate(boolean useOptimisticStore, boolean importMode) { UpdaterSnapshot snapshot = snapshotUpdaterState(); boolean[] firstAttempt = {true}; flushInOneTransaction( + List.of(original), () -> { restoreUpdaterState(snapshot, !firstAttempt[0]); firstAttempt[0] = false; - flushUpdateBody(useOptimisticStore, importMode); + flushUpdateBody(useOptimisticStore || useOptimisticLocking, importMode); }); if (entityStored) { try (var ignored = phase("entityUpdateCacheEvict")) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ListFilter.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ListFilter.java index 7a2e1fb29a4e..55aeca829216 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ListFilter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ListFilter.java @@ -49,6 +49,9 @@ public class ListFilter extends Filter { private static final String TASK_STATUS_GROUP_CLOSED = "closed"; private static final String ONTOLOGY_AXIOM_TABLE = "ontology_axiom_entity"; private static final String ONTOLOGY_CHANGE_SET_TABLE = "ontology_change_set_entity"; + private static final String ONTOLOGY_CONCEPT_TABLE = "ontology_concept_entity"; + private static final String ONTOLOGY_PROPERTY_TABLE = "ontology_property_entity"; + private static final String ONTOLOGY_RELEASE_TABLE = "ontology_release_entity"; // An fqnHash joins one MD5 hex digest per FQN segment. private static final int FQN_HASH_SEGMENT_LENGTH = 32; private static final String FQN_HASH_COLUMN = "fqnHash"; @@ -169,21 +172,24 @@ public String getCondition(String tableName) { conditions.add(getPrimaryEntityCondition()); conditions.add(getFolderCondition()); conditions.add(getAssetCondition()); - conditions.add(getGlossaryIdCondition(tableName)); + conditions.add(getOntologyIdCondition(tableName)); conditions.add(getOntologyChangeSetStateCondition(tableName)); conditions.add(getMemorySourcedChangeSetCondition(tableName)); String condition = addCondition(conditions); return condition.isEmpty() ? "WHERE TRUE" : "WHERE " + condition; } - private String getGlossaryIdCondition(String tableName) { - String glossaryId = queryParams.get("glossaryId"); - String condition = ""; - if (!nullOrEmpty(glossaryId) && tableMatches(tableName, ONTOLOGY_AXIOM_TABLE)) { - queryParams.put("glossaryIdParam", glossaryId); - condition = qualifyColumn(tableName, "glossaryId") + " = :glossaryIdParam"; + private String getOntologyIdCondition(final String tableName) { + final String ontologyId = queryParams.get("ontologyId"); + if (!nullOrEmpty(ontologyId) + && (tableMatches(tableName, ONTOLOGY_CONCEPT_TABLE) + || tableMatches(tableName, ONTOLOGY_AXIOM_TABLE) + || tableMatches(tableName, ONTOLOGY_PROPERTY_TABLE) + || tableMatches(tableName, ONTOLOGY_RELEASE_TABLE))) { + queryParams.put("ontologyIdParam", ontologyId); + return qualifyColumn(tableName, "ontologyId") + " = :ontologyIdParam"; } - return condition; + return ""; } /** Matches any of the comma-separated states, each bound as its own parameter. */ diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyAxiomRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyAxiomRepository.java index 73b38d6c6f2c..5a9709d7548a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyAxiomRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyAxiomRepository.java @@ -15,149 +15,52 @@ import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; -import jakarta.ws.rs.BadRequestException; -import java.net.URI; import java.util.List; import org.openmetadata.schema.api.data.OntologyProfileReport; -import org.openmetadata.schema.entity.data.Glossary; import org.openmetadata.schema.entity.data.OntologyAxiom; -import org.openmetadata.schema.type.EntityStatus; import org.openmetadata.schema.type.Include; -import org.openmetadata.schema.type.Relationship; -import org.openmetadata.schema.type.change.ChangeSource; import org.openmetadata.service.Entity; import org.openmetadata.service.ontology.OwlProfileGuard; -import org.openmetadata.service.ontology.RelationshipTypeResolver; import org.openmetadata.service.resources.ontology.OntologyAxiomResource; -import org.openmetadata.service.util.EntityUtil.Fields; -import org.openmetadata.service.util.EntityUtil.RelationIncludes; -import org.openmetadata.service.util.FullyQualifiedName; +import org.openmetadata.service.util.FreshReadScope; @Repository -public class OntologyAxiomRepository extends EntityRepository { - private static final String UPDATE_FIELDS = - "axiomType,subjectIri,expressions,propertyIri,targetIri,literal,provenance,entityStatus"; - private final OwlProfileGuard profileGuard; +public class OntologyAxiomRepository extends OntologyEntityRepository { + private static final OwlProfileGuard SHAPE_GUARD = new OwlProfileGuard(iri -> null); public OntologyAxiomRepository() { super( - OntologyAxiomResource.COLLECTION_PATH, Entity.ONTOLOGY_AXIOM, OntologyAxiom.class, Entity.getCollectionDAO().ontologyAxiomDAO(), - UPDATE_FIELDS, - UPDATE_FIELDS); - final RelationshipTypeResolver relationshipTypes = - new RelationshipTypeResolver(Entity.getCollectionDAO().relationshipTypeDAO()); - profileGuard = - new OwlProfileGuard( - relationshipTypes::isSimple, - new PersistentVocabularyIndex(Entity.getCollectionDAO().ontologyAxiomDAO())); - defaultEntityStatus = EntityStatus.DRAFT; + OntologyAxiomResource.COLLECTION_PATH, + "ontology,axiomType,subjectIri,expressions,propertyIri,targetIri,literal,provenance"); } @Override - public void setFields( - final OntologyAxiom entity, final Fields fields, final RelationIncludes relationIncludes) {} - - @Override - public void clearFields(final OntologyAxiom entity, final Fields fields) {} - - @Override - public void prepare(final OntologyAxiom entity, final boolean update) { - final Glossary glossary = requireEditableGlossary(entity); - entity.setGlossary(glossary.getEntityReference()); - entity.setFullyQualifiedName( - FullyQualifiedName.add(glossary.getFullyQualifiedName(), entity.getName())); + protected void validateDefinition(final OntologyAxiom entity) { + requireAbsoluteIri(entity.getSubjectIri(), "subjectIri"); + SHAPE_GUARD.validateOrThrow(entity); entity.setExpressions(List.copyOf(listOrEmpty(entity.getExpressions()))); - profileGuard.validateOrThrow(entity); } public OntologyProfileReport validate(final OntologyAxiom entity) { - prepare(entity, false); - return profileGuard.validate(entity); - } - - private static Glossary requireEditableGlossary(final OntologyAxiom entity) { - final Glossary glossary = - Entity.getEntity(Entity.GLOSSARY, entity.getGlossary().getId(), "", Include.NON_DELETED); - final boolean isReadOnly = - glossary.getOntologyConfiguration() != null - && Boolean.TRUE.equals(glossary.getOntologyConfiguration().getReadOnly()); - if (isReadOnly) { - throw new BadRequestException("Reference ontology '" + glossary.getName() + "' is read-only"); - } - return glossary; - } - - @Override - public void storeEntity(final OntologyAxiom entity, final boolean update) { - store(entity, update); - } - - @Override - public void storeRelationships(final OntologyAxiom entity) { - addRelationship( - entity.getGlossary().getId(), - entity.getId(), - Entity.GLOSSARY, - Entity.ONTOLOGY_AXIOM, - Relationship.CONTAINS); - } - - @Override - public EntityUpdater getUpdater( - final OntologyAxiom original, - final OntologyAxiom updated, - final Operation operation, - final ChangeSource changeSource) { - return new OntologyAxiomUpdater(original, updated, operation); - } - - public class OntologyAxiomUpdater extends EntityUpdater { - OntologyAxiomUpdater( - final OntologyAxiom original, final OntologyAxiom updated, final Operation operation) { - super(original, updated, operation); - } - - @Override - public void entitySpecificUpdate(final boolean consolidatingChanges) { - if (!original.getGlossary().getId().equals(updated.getGlossary().getId())) { - throw new BadRequestException("An ontology axiom cannot move between glossaries"); - } - recordAxiomChanges(); - recordAssertionChanges(); - recordGovernanceChanges(); - } - - private void recordAxiomChanges() { - recordChange("axiomType", original.getAxiomType(), updated.getAxiomType()); - recordChange("subjectIri", original.getSubjectIri(), updated.getSubjectIri()); - recordChange("expressions", original.getExpressions(), updated.getExpressions(), true); - } - - private void recordAssertionChanges() { - recordChange("propertyIri", original.getPropertyIri(), updated.getPropertyIri()); - recordChange("targetIri", original.getTargetIri(), updated.getTargetIri()); - recordChange("literal", original.getLiteral(), updated.getLiteral()); - } - - private void recordGovernanceChanges() { - recordChange("provenance", original.getProvenance(), updated.getProvenance()); - recordChange("entityStatus", original.getEntityStatus(), updated.getEntityStatus()); - } - } - - private record PersistentVocabularyIndex(CollectionDAO.OntologyAxiomDAO dao) - implements OwlProfileGuard.VocabularyIndex { - @Override - public boolean isClass(final URI iri) { - return iri != null && dao.countClassSubjects(iri.toString()) > 0; - } - - @Override - public boolean isIndividual(final URI iri) { - return iri != null && dao.countIndividualSubjects(iri.toString()) > 0; + final var shape = SHAPE_GUARD.validate(entity); + if (!shape.getValid()) { + return shape; } + return executeInTransaction( + () -> { + try (FreshReadScope.Handle ignored = FreshReadScope.enter()) { + prepare(entity, false); + setFullyQualifiedName(entity); + final var existing = + findByNameOrNull(entity.getFullyQualifiedName(), Include.NON_DELETED); + if (existing != null) { + entity.setId(existing.getId()); + } + return new OntologyWorkingModel(daoCollection).validate(entity); + } + }); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyChangeSetRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyChangeSetRepository.java index 194e4a588055..7cfdb02632bf 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyChangeSetRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyChangeSetRepository.java @@ -20,8 +20,10 @@ import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.UUID; +import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.entity.data.Glossary; import org.openmetadata.schema.entity.data.OntologyChangeSet; import org.openmetadata.schema.type.EntityReference; @@ -55,6 +57,33 @@ public OntologyChangeSetRepository() { UPDATE_FIELDS); } + @Override + protected EntityReference getParentReference(final OntologyChangeSet entity) { + return entity.getOntology(); + } + + @Override + protected String getInheritableFields(final String parentType) { + return Entity.ONTOLOGY.equals(parentType) ? "owners" : super.getInheritableFields(parentType); + } + + @Override + public EntityInterface getParentEntity(final OntologyChangeSet entity, final String fields) { + return entity.getOntology() == null + ? null + : Entity.getEntity(entity.getOntology(), fields, Include.ALL); + } + + @Override + protected void applyInheritance( + final OntologyChangeSet entity, final Fields fields, final EntityInterface parent) { + if (entity.getOntology() != null) { + inheritOwners(entity, fields, parent); + } else { + super.applyInheritance(entity, fields, parent); + } + } + public List findOpenBySourceMemoryId(final UUID memoryId) { return daoCollection .ontologyChangeSetDAO() @@ -84,7 +113,13 @@ public void prepare(final OntologyChangeSet entity, final boolean update) { // Discarding must always succeed: a draft whose glossary was deleted, or whose planned // glossary has since been created elsewhere, would otherwise be impossible to clean up. if (entity.getState() != OntologyChangeSetState.DISCARDED) { - entity.setGlossaries(validateGlossaries(entity, update)); + if (entity.getOntology() != null) { + entity.setOntology( + Entity.getEntityReferenceById( + Entity.ONTOLOGY, entity.getOntology().getId(), Include.NON_DELETED)); + } else { + entity.setGlossaries(validateGlossaries(entity, update)); + } } validateStateResult(entity); } @@ -181,7 +216,15 @@ public void storeEntity(final OntologyChangeSet entity, final boolean update) { @Override public void storeRelationships(final OntologyChangeSet entity) { - for (final EntityReference glossary : entity.getGlossaries()) { + if (entity.getOntology() != null) { + addRelationship( + entity.getOntology().getId(), + entity.getId(), + Entity.ONTOLOGY, + Entity.ONTOLOGY_CHANGE_SET, + Relationship.CONTAINS); + } + for (final EntityReference glossary : listOrEmpty(entity.getGlossaries())) { // A glossary the draft plans to create does not exist yet; it is linked once applied. if (plannedGlossary(entity, glossary.getId()) == null) { storeGlossaryRelationship(entity, glossary); @@ -336,7 +379,11 @@ public class OntologyChangeSetUpdater extends EntityUpdater { @Override public void entitySpecificUpdate(final boolean consolidatingChanges) { - if (!original.getGlossaries().equals(updated.getGlossaries())) { + if (!listOrEmpty(original.getGlossaries()).equals(listOrEmpty(updated.getGlossaries())) + || !Objects.equals( + original.getOntology() == null ? null : original.getOntology().getId(), + updated.getOntology() == null ? null : updated.getOntology().getId()) + || !Objects.equals(original.getBaseWorkingRevision(), updated.getBaseWorkingRevision())) { throw new BadRequestException("Ontology change set scope is immutable"); } if (updated.getState() == OntologyChangeSetState.APPLIED diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyConceptRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyConceptRepository.java new file mode 100644 index 000000000000..36c1614cfa5f --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyConceptRepository.java @@ -0,0 +1,163 @@ +package org.openmetadata.service.jdbi3; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.json.JsonPatch; +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.function.BiConsumer; +import org.apache.commons.codec.digest.DigestUtils; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.type.OntologyReferenceRole; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.ontology.OntologyClassHierarchy; +import org.openmetadata.service.ontology.OntologyReleaseManifest; +import org.openmetadata.service.util.EntityUtil.Fields; +import org.openmetadata.service.util.EntityUtil.RelationIncludes; + +@Repository +public class OntologyConceptRepository extends OntologyEntityRepository { + public static final String COLLECTION_PATH = "/v1/ontologyConcepts/"; + private static final String SUPER_CLASSES = "superClasses"; + private static final String SUB_CLASSES = "subClasses"; + private static final List DERIVED_FIELDS = List.of(SUPER_CLASSES, SUB_CLASSES); + + public OntologyConceptRepository() { + super( + Entity.ONTOLOGY_CONCEPT, + OntologyConcept.class, + Entity.getCollectionDAO().ontologyConceptDAO(), + COLLECTION_PATH, + "ontology,iri,conceptType,synonyms,conceptMappings"); + fieldFetchers.put(SUPER_CLASSES, this::fetchSuperClasses); + fieldFetchers.put(SUB_CLASSES, this::fetchSubClasses); + } + + @Override + protected void validateDefinition(final OntologyConcept entity) { + if (entity.getConceptType() == null) { + throw new BadRequestException("Ontology concept type is required"); + } + entity.withSuperClasses(null).withSubClasses(null); + } + + @Override + public void setFields( + final OntologyConcept entity, final Fields fields, final RelationIncludes includes) { + if (fields.contains(SUPER_CLASSES)) { + fetchSuperClasses(List.of(entity), fields); + } + if (fields.contains(SUB_CLASSES)) { + fetchSubClasses(List.of(entity), fields); + } + } + + @Override + public void clearFields(final OntologyConcept entity, final Fields fields) { + if (!fields.contains(SUPER_CLASSES)) { + entity.setSuperClasses(null); + } + if (!fields.contains(SUB_CLASSES)) { + entity.setSubClasses(null); + } + } + + @Override + protected void validatePatch( + final OntologyConcept original, final OntologyConcept updated, final JsonPatch patch) { + super.validatePatch(original, updated, patch); + if (JsonUtils.extractPatchedFields(patch).stream().anyMatch(DERIVED_FIELDS::contains)) { + throw new BadRequestException( + "Concept superClasses and subClasses are derived from ontology axioms"); + } + } + + @Override + protected List getFieldsStrippedFromStorageJson() { + return DERIVED_FIELDS; + } + + @Override + protected String serializeForVersionHistory(final OntologyConcept entity) { + final ObjectNode node = (ObjectNode) JsonUtils.valueToTree(entity); + node.remove(DERIVED_FIELDS); + return node.toString(); + } + + private void fetchSuperClasses(final List concepts, final Fields fields) { + if (fields.contains(SUPER_CLASSES)) { + fetchHierarchy( + concepts, OntologyReferenceRole.AXIOM_SUBJECT, OntologyConcept::setSuperClasses); + } + } + + private void fetchSubClasses(final List concepts, final Fields fields) { + if (fields.contains(SUB_CLASSES)) { + fetchHierarchy(concepts, OntologyReferenceRole.SUPER_CLASS, OntologyConcept::setSubClasses); + } + } + + private void fetchHierarchy( + final List concepts, + final OntologyReferenceRole role, + final BiConsumer> setter) { + if (concepts.isEmpty()) { + return; + } + final var json = + daoCollection + .ontologyReferenceDAO() + .listHierarchyAxioms( + concepts.stream() + .map(concept -> concept.getOntology().getId().toString()) + .distinct() + .toList(), + concepts.stream() + .map(concept -> DigestUtils.sha256Hex(concept.getIri().toString())) + .distinct() + .toList(), + role.value(), + OntologyReleaseManifest.MAX_MEMBERS + 1); + if (json.size() > OntologyReleaseManifest.MAX_MEMBERS) { + throw new BadRequestException("Concept hierarchy lookup exceeds the 10000 axiom limit"); + } + final Map> edges = new HashMap<>(); + for (final String value : json) { + final var axiom = JsonUtils.readValue(value, OntologyAxiom.class); + for (final URI parent : OntologyClassHierarchy.namedParents(axiom)) { + final String source = + role == OntologyReferenceRole.AXIOM_SUBJECT + ? axiom.getSubjectIri().toString() + : parent.toString(); + final String target = + role == OntologyReferenceRole.AXIOM_SUBJECT + ? parent.toString() + : axiom.getSubjectIri().toString(); + edges + .computeIfAbsent( + new ClassKey(axiom.getOntology().getId(), source), ignored -> new TreeSet<>()) + .add(target); + } + } + concepts.forEach( + concept -> + setter.accept( + concept, + edges + .getOrDefault( + new ClassKey(concept.getOntology().getId(), concept.getIri().toString()), + Set.of()) + .stream() + .map(URI::create) + .toList())); + } + + private record ClassKey(UUID ontologyId, String iri) {} +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyEntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyEntityRepository.java new file mode 100644 index 000000000000..435fa921ae59 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyEntityRepository.java @@ -0,0 +1,237 @@ +package org.openmetadata.service.jdbi3; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.json.JsonPatch; +import java.net.URI; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; +import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.OntologyEntityInterface; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.Relationship; +import org.openmetadata.schema.type.change.ChangeSource; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.ontology.OntologyIriMinter; +import org.openmetadata.service.util.EntityUtil.Fields; +import org.openmetadata.service.util.EntityUtil.RelationIncludes; +import org.openmetadata.service.util.FullyQualifiedName; + +/** Common persistence and lifecycle invariants for independent ontology definitions. */ +public abstract class OntologyEntityRepository + extends EntityRepository { + private final List definitionFields; + protected final OntologyWorkingState workingState; + + protected OntologyEntityRepository( + final String entityType, + final Class entityClass, + final EntityDAO dao, + final String collectionPath, + final String definitionFields) { + super(collectionPath, entityType, entityClass, dao, definitionFields, definitionFields); + this.definitionFields = List.of(definitionFields.split(",")); + workingState = new OntologyWorkingState(daoCollection); + } + + @Override + public void prepare(final T entity, final boolean update) { + if (entity.getOntology() != null) { + final EntityReference reference = entity.getOntology(); + if (!Entity.ONTOLOGY.equals(reference.getType())) { + throw new BadRequestException("Ontology definitions must belong to an ontology"); + } + final Ontology ontology = + Entity.getEntity(Entity.ONTOLOGY, reference.getId(), "", Include.NON_DELETED); + entity.setOntology(ontology.getEntityReference()); + if (entity instanceof OntologyEntityInterface definition) { + initializeIri(definition, ontology); + } + } else if (!Entity.ONTOLOGY.equals(entityType)) { + throw new BadRequestException("An owning ontology is required"); + } + if (entity instanceof OntologyEntityInterface definition) { + requireAbsoluteIri(definition.getIri(), "iri"); + } + validateDefinition(entity); + } + + private void initializeIri(final OntologyEntityInterface entity, final Ontology ontology) { + if (entity.getIri() == null) { + final T existing = + findByNameOrNull( + FullyQualifiedName.add(ontology.getFullyQualifiedName(), entity.getName()), + Include.ALL); + entity.setIri( + existing == null + ? OntologyIriMinter.mint(ontology.getNamespace(), entity.getId()) + : ((OntologyEntityInterface) existing).getIri()); + } + } + + protected abstract void validateDefinition(T entity); + + protected static void requireAbsoluteIri(final URI iri, final String field) { + if (iri == null || !iri.isAbsolute()) { + throw new BadRequestException("Ontology " + field + " must be an absolute IRI"); + } + } + + @Override + public void setFullyQualifiedName(final T entity) { + entity.setFullyQualifiedName( + entity.getOntology() == null + ? FullyQualifiedName.build(entity.getName()) + : FullyQualifiedName.add( + entity.getOntology().getFullyQualifiedName(), entity.getName())); + } + + @Override + protected EntityReference getParentReference(final T entity) { + return entity.getOntology(); + } + + @Override + public Ontology getParentEntity(final T entity, final String fields) { + return entity.getOntology() == null + ? null + : Entity.getEntity(entity.getOntology(), fields, Include.ALL); + } + + @Override + protected String getInheritableFields() { + return Entity.ONTOLOGY_RELEASE.equals(entityType) ? "owners,reviewers" : "owners"; + } + + @Override + protected boolean requiresParentForInheritance(final T entity, final Fields fields) { + return entity.getOntology() != null + && ((Entity.ONTOLOGY_RELEASE.equals(entityType) && fields.contains("reviewers")) + || requiresParentForOwnersOrDomains(entity, fields)); + } + + @Override + protected void applyInheritance( + final T entity, final Fields fields, final EntityInterface parent) { + inheritOwners(entity, fields, parent); + if (Entity.ONTOLOGY_RELEASE.equals(entityType)) { + inheritReviewers(entity, fields, getParentEntity(entity, "reviewers")); + } + } + + @Override + public void setFields(final T entity, final Fields fields, final RelationIncludes includes) {} + + @Override + public void clearFields(final T entity, final Fields fields) {} + + @Override + public void storeEntity(final T entity, final boolean update) { + store(entity, update); + } + + @Override + protected R executeEntityWrite(final List entities, final Supplier work) { + return executeInTransaction(() -> workingState.mutate(dao, entities, work)); + } + + @Override + protected void flushInOneTransaction(final Runnable work) { + executeInTransaction( + () -> { + work.run(); + return null; + }); + } + + @Override + public void storeRelationships(final T entity) { + if (entity.getOntology() != null) { + addRelationship( + entity.getOntology().getId(), + entity.getId(), + Entity.ONTOLOGY, + entityType, + Relationship.CONTAINS); + } + } + + @Override + protected void validatePatch(final T original, final T updated, final JsonPatch patch) { + if (updated instanceof OntologyEntityInterface definition) { + requireAbsoluteIri(definition.getIri(), "iri"); + } + requireUnchangedName(original, updated); + } + + private void requireUnchangedName(final T original, final T updated) { + if (!Objects.equals(original.getName(), updated.getName())) { + throw new BadRequestException( + "Ontology definition names are immutable; change the display name instead"); + } + } + + @Override + public EntityUpdater getUpdater( + final T original, final T updated, final Operation operation, final ChangeSource source) { + if (operation.isPut() && Boolean.TRUE.equals(original.getDeleted())) { + useRestoredRevision(original); + } + return new OntologyUpdater(original, updated, operation, source); + } + + private void useRestoredRevision(final T original) { + final T current = find(original.getId(), Include.ALL); + if (!Boolean.TRUE.equals(current.getDeleted())) { + // PUT restores first, within the same ontology lock; compare the edit to that revision. + original.setVersion(current.getVersion()); + original.setUpdatedAt(current.getUpdatedAt()); + original.setDeleted(false); + } + } + + protected class OntologyUpdater extends EntityUpdater { + protected OntologyUpdater( + final T original, final T updated, final Operation operation, final ChangeSource source) { + super(original, updated, operation, source); + setUseOptimisticLocking(true); + } + + @Override + void updateEntityStatus(final boolean consolidatingChanges) { + // Only immutable releases have an approval status. + } + + @Override + protected void entitySpecificUpdate(final boolean consolidatingChanges) { + requireUnchangedIdentity(); + setInheritedFields(updated, getFields(getInheritableFields())); + final JsonNode before = JsonUtils.getObjectMapper().valueToTree(original); + final JsonNode after = JsonUtils.getObjectMapper().valueToTree(updated); + for (final String field : definitionFields) { + recordChange(field, before.get(field), after.get(field), true); + } + } + + private void requireUnchangedIdentity() { + requireUnchangedName(original, updated); + if (original instanceof OntologyEntityInterface definition + && !Objects.equals( + definition.getIri().toString(), + ((OntologyEntityInterface) updated).getIri().toString())) { + throw new BadRequestException("An ontology definition's semantic IRI is immutable"); + } + final EntityReference before = original.getOntology(); + final EntityReference after = updated.getOntology(); + if (!Objects.equals( + before == null ? null : before.getId(), after == null ? null : after.getId())) { + throw new BadRequestException("An ontology definition cannot move between ontologies"); + } + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyPropertyRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyPropertyRepository.java new file mode 100644 index 000000000000..ea3ec507788d --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyPropertyRepository.java @@ -0,0 +1,131 @@ +package org.openmetadata.service.jdbi3; + +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; + +import java.net.URI; +import java.util.Collection; +import java.util.EnumSet; +import java.util.List; +import org.apache.jena.datatypes.RDFDatatype; +import org.apache.jena.datatypes.TypeMapper; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.OntologyLiteral; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.RelationshipCharacteristic; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; + +@Repository +public class OntologyPropertyRepository extends OntologyEntityRepository { + public static final String COLLECTION_PATH = "/v1/ontologyProperties/"; + + public OntologyPropertyRepository() { + super( + Entity.ONTOLOGY_PROPERTY, + OntologyProperty.class, + Entity.getCollectionDAO().ontologyPropertyDAO(), + COLLECTION_PATH, + "ontology,iri,kind,domain,range,enumValues,unit,isIdentifier,inverse,characteristics,propertyChain,disjointWith"); + } + + @Override + protected void validateDefinition(final OntologyProperty entity) { + if (entity.getKind() == null) { + throw new BadRequestException("Ontology property kind is required"); + } + validateDistinctIris(entity.getDomain(), "domain"); + validateDistinctIris(entity.getRange(), "range"); + validateIris(entity.getPropertyChain(), "propertyChain"); + validateDistinctIris(entity.getDisjointWith(), "disjointWith"); + if (entity.getInverse() != null) { + requireAbsoluteIri(entity.getInverse(), "inverse"); + } + validatePropertyKind(entity); + validateDataMetadata(entity); + } + + private static void validateDataMetadata(final OntologyProperty entity) { + final List values = entity.getEnumValues(); + if (entity.getKind() != OntologyPropertyKind.DATA + && (!nullOrEmpty(values) + || entity.getUnit() != null + || Boolean.TRUE.equals(entity.getIsIdentifier()))) { + throw new BadRequestException( + "Only data properties can define enum values, units or identifier intent"); + } + if (entity.getUnit() != null + && (entity.getUnit().isBlank() || entity.getUnit().length() > 256)) { + throw new BadRequestException("An ontology property unit requires 1 to 256 characters"); + } + validateEnumValues(values); + } + + private static void validateEnumValues(final List values) { + if (values != null) { + if (values.size() > 1000) { + throw new BadRequestException("An ontology property supports at most 1000 enum values"); + } + values.forEach(OntologyPropertyRepository::validateLiteral); + if (values.stream().map(JsonUtils::pojoToJson).distinct().count() != values.size()) { + throw new BadRequestException("Ontology property enum values must be unique"); + } + } + } + + private static void validateLiteral(final OntologyLiteral literal) { + if (literal == null || literal.getValue() == null) { + throw new BadRequestException("An ontology enum literal requires a value"); + } + if (literal.getDatatypeIri() != null) { + requireAbsoluteIri(literal.getDatatypeIri(), "enum datatype"); + final RDFDatatype datatype = + TypeMapper.getInstance().getTypeByName(literal.getDatatypeIri().toString()); + if (datatype != null && !datatype.isValid(literal.getValue())) { + throw new BadRequestException("An ontology enum value is invalid for its datatype"); + } + } + } + + private static void validatePropertyKind(final OntologyProperty entity) { + if (entity.getKind() != OntologyPropertyKind.OBJECT + && (entity.getInverse() != null || !nullOrEmpty(entity.getPropertyChain()))) { + throw new BadRequestException("Only object properties can have inverses or property chains"); + } + if (!nullOrEmpty(entity.getPropertyChain()) && entity.getPropertyChain().size() < 2) { + throw new BadRequestException("A property chain requires at least two object properties"); + } + validateCharacteristics(entity); + if (entity.getKind() == OntologyPropertyKind.ANNOTATION + && !nullOrEmpty(entity.getDisjointWith())) { + throw new BadRequestException("Annotation properties cannot declare disjointness"); + } + } + + private static void validateCharacteristics(final OntologyProperty entity) { + final EnumSet allowed = + switch (entity.getKind()) { + case OBJECT -> EnumSet.allOf(RelationshipCharacteristic.class); + case DATA -> EnumSet.of(RelationshipCharacteristic.FUNCTIONAL); + case ANNOTATION -> EnumSet.noneOf(RelationshipCharacteristic.class); + }; + if (!nullOrEmpty(entity.getCharacteristics()) + && !allowed.containsAll(entity.getCharacteristics())) { + throw new BadRequestException( + "Unsupported characteristics for " + entity.getKind() + " property"); + } + } + + private static void validateIris(final Collection iris, final String field) { + if (!nullOrEmpty(iris)) { + iris.forEach(iri -> requireAbsoluteIri(iri, field)); + } + } + + private static void validateDistinctIris(final Collection iris, final String field) { + validateIris(iris, field); + if (!nullOrEmpty(iris) && iris.stream().map(URI::toString).distinct().count() != iris.size()) { + throw new BadRequestException(field + " requires unique IRIs"); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReferenceDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReferenceDAO.java new file mode 100644 index 000000000000..736e43558786 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReferenceDAO.java @@ -0,0 +1,143 @@ +package org.openmetadata.service.jdbi3; + +import static org.openmetadata.service.jdbi3.locator.ConnectionType.MYSQL; +import static org.openmetadata.service.jdbi3.locator.ConnectionType.POSTGRES; + +import java.net.URI; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.UUID; +import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.statement.StatementContext; +import org.jdbi.v3.sqlobject.config.RegisterRowMapper; +import org.jdbi.v3.sqlobject.customizer.Bind; +import org.jdbi.v3.sqlobject.customizer.BindList; +import org.jdbi.v3.sqlobject.customizer.Define; +import org.jdbi.v3.sqlobject.statement.SqlQuery; +import org.jdbi.v3.sqlobject.statement.SqlUpdate; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.OntologyReference; +import org.openmetadata.schema.type.OntologyReferenceRole; +import org.openmetadata.service.jdbi3.locator.ConnectionAwareSqlQuery; +import org.openmetadata.service.util.jdbi.BindUUID; + +public interface OntologyReferenceDAO { + String TARGET = " FROM ontology_reference WHERE ontologyId = :ontologyId AND iriHash = :iriHash "; + String AFTER = + " AND (sourceType > :afterType OR (sourceType = :afterType AND " + + "(sourceId > :afterId OR (sourceId = :afterId AND role > :afterRole)))) " + + "ORDER BY sourceType, sourceId, role LIMIT :limit"; + + @SqlUpdate( + "INSERT INTO ontology_reference (ontologyId, sourceType, sourceId, role, iri, iriHash) " + + "VALUES (:ontologyId, :sourceType, :sourceId, :role, :iri, :iriHash)") + void insert( + @BindUUID("ontologyId") UUID ontologyId, + @Bind("sourceType") String sourceType, + @BindUUID("sourceId") UUID sourceId, + @Bind("role") String role, + @Bind("iri") String iri, + @Bind("iriHash") String iriHash); + + default void insert(OntologyReference reference, String iriHash) { + insert( + reference.getOntologyId(), + reference.getSource().getType(), + reference.getSource().getId(), + reference.getRole().value(), + reference.getIri().toString(), + iriHash); + } + + @SqlUpdate( + "DELETE FROM ontology_reference WHERE sourceType = :sourceType AND sourceId = :sourceId") + void delete(@Bind("sourceType") String sourceType, @BindUUID("sourceId") UUID sourceId); + + @ConnectionAwareSqlQuery( + value = "SELECT *" + TARGET + "AND BINARY iri = BINARY :iri" + AFTER, + connectionType = MYSQL) + @ConnectionAwareSqlQuery( + value = "SELECT *" + TARGET + "AND iri = :iri" + AFTER, + connectionType = POSTGRES) + @RegisterRowMapper(ReferenceMapper.class) + List list( + @BindUUID("ontologyId") UUID ontologyId, + @Bind("iriHash") String iriHash, + @Bind("iri") String iri, + @Bind("afterType") String afterType, + @Bind("afterId") String afterId, + @Bind("afterRole") String afterRole, + @Bind("limit") int limit); + + @ConnectionAwareSqlQuery( + value = "SELECT COUNT(*)" + TARGET + "AND BINARY iri = BINARY :iri", + connectionType = MYSQL) + @ConnectionAwareSqlQuery( + value = "SELECT COUNT(*)" + TARGET + "AND iri = :iri", + connectionType = POSTGRES) + int count( + @BindUUID("ontologyId") UUID ontologyId, + @Bind("iriHash") String iriHash, + @Bind("iri") String iri); + + @SqlQuery( + "SELECT * FROM ontology_reference WHERE ontologyId = :ontologyId AND iriHash IN () " + + "ORDER BY sourceType, sourceId, role LIMIT :limit FOR UPDATE") + @RegisterRowMapper(ReferenceMapper.class) + List referencingForUpdate( + @BindUUID("ontologyId") UUID ontologyId, + @BindList("iriHashes") List iriHashes, + @Bind("limit") int limit); + + @SqlQuery( + "SELECT a.json FROM ontology_axiom_entity a JOIN ontology_reference r " + + "ON r.sourceId = a.id AND r.ontologyId = a.ontologyId " + + "WHERE a.ontologyId = :ontologyId AND a.deleted = FALSE AND a.axiomType = 'SUBCLASS_OF' " + + "AND r.sourceType = 'ontologyAxiom' AND r.role = 'AXIOM_SUBJECT' AND r.iriHash IN () " + + "ORDER BY a.name, a.id LIMIT :limit ") + List ancestors( + @BindUUID("ontologyId") UUID ontologyId, + @BindList("iriHashes") List iriHashes, + @Bind("limit") int limit, + @Define("locking") String locking); + + @SqlQuery( + "SELECT p.json FROM ontology_property_entity p JOIN ontology_reference r " + + "ON r.sourceId = p.id AND r.ontologyId = p.ontologyId " + + "WHERE p.ontologyId = :ontologyId AND p.deleted = FALSE " + + "AND r.sourceType = 'ontologyProperty' AND r.role = 'INVERSE' AND r.iriHash IN () " + + "ORDER BY p.name, p.id LIMIT :limit ") + List incomingInverses( + @BindUUID("ontologyId") UUID ontologyId, + @BindList("iriHashes") List iriHashes, + @Bind("limit") int limit, + @Define("locking") String locking); + + @SqlQuery( + "SELECT a.json FROM ontology_axiom_entity a WHERE a.ontologyId IN () " + + "AND a.deleted = FALSE AND a.axiomType = 'SUBCLASS_OF' AND EXISTS " + + "(SELECT 1 FROM ontology_reference r WHERE r.sourceId = a.id " + + "AND r.sourceType = 'ontologyAxiom' AND r.role = :role AND r.iriHash IN ()) " + + "ORDER BY a.name, a.id LIMIT :limit") + List listHierarchyAxioms( + @BindList("ontologyIds") List ontologyIds, + @BindList("iriHashes") List iriHashes, + @Bind("role") String role, + @Bind("limit") int limit); + + class ReferenceMapper implements RowMapper { + @Override + public OntologyReference map(final ResultSet rows, final StatementContext context) + throws SQLException { + return new OntologyReference() + .withOntologyId(UUID.fromString(rows.getString("ontologyId"))) + .withSource( + new EntityReference() + .withType(rows.getString("sourceType")) + .withId(UUID.fromString(rows.getString("sourceId")))) + .withRole(OntologyReferenceRole.fromValue(rows.getString("role"))) + .withIri(URI.create(rows.getString("iri"))); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReferenceIndex.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReferenceIndex.java new file mode 100644 index 000000000000..061808e77af2 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReferenceIndex.java @@ -0,0 +1,83 @@ +package org.openmetadata.service.jdbi3; + +import java.net.URI; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.apache.commons.codec.digest.DigestUtils; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.type.OntologyReference; +import org.openmetadata.schema.type.OntologyReferenceRole; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.ontology.OntologyReferences; +import org.openmetadata.service.util.RestUtil; + +/** Maintains derived working references inside the definition writer's SQL transaction. */ +final class OntologyReferenceIndex { + private static final Set SOURCE_TYPES = + Set.of(Entity.ONTOLOGY_CONCEPT, Entity.ONTOLOGY_PROPERTY, Entity.ONTOLOGY_AXIOM); + private final OntologyReferenceDAO dao; + + OntologyReferenceIndex(final CollectionDAO dao) { + this.dao = dao.ontologyReferenceDAO(); + } + + void replace(final String sourceType, final UUID sourceId, final OntologyDefinition current) { + dao.delete(sourceType, sourceId); + if (current != null) { + for (final OntologyReference reference : OntologyReferences.collect(current)) { + dao.insert(reference, DigestUtils.sha256Hex(reference.getIri().toString())); + } + } + } + + ResultList list( + final UUID ontologyId, final URI iri, final int limit, final String after) { + if (iri == null || !iri.isAbsolute() || limit < 1 || limit > 1000) { + throw new BadRequestException( + "Reference lookup requires an absolute IRI and a limit from 1 to 1000"); + } + final Cursor cursor = Cursor.parse(after); + final String value = iri.toString(); + final String hash = DigestUtils.sha256Hex(value); + final List matches = + dao.list(ontologyId, hash, value, cursor.type(), cursor.id(), cursor.role(), limit + 1); + final List page = + List.copyOf(matches.subList(0, Math.min(limit, matches.size()))); + return new ResultList<>( + page, + null, + matches.size() > limit ? Cursor.of(page.getLast()) : null, + dao.count(ontologyId, hash, value)); + } + + private record Cursor(String type, String id, String role) { + static Cursor parse(final String after) { + if (after == null) { + return new Cursor("", "", ""); + } + try { + final String[] fields = RestUtil.decodeCursor(after).split(":", -1); + if (fields.length != 3 || !SOURCE_TYPES.contains(fields[0])) { + throw new IllegalArgumentException(); + } + return new Cursor( + fields[0], + UUID.fromString(fields[1]).toString(), + OntologyReferenceRole.fromValue(fields[2]).value()); + } catch (IllegalArgumentException invalid) { + throw new BadRequestException("Invalid ontology reference cursor"); + } + } + + static String of(final OntologyReference reference) { + return reference.getSource().getType() + + ":" + + reference.getSource().getId() + + ":" + + reference.getRole().value(); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReleaseDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReleaseDAO.java new file mode 100644 index 000000000000..346b33120836 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReleaseDAO.java @@ -0,0 +1,190 @@ +package org.openmetadata.service.jdbi3; + +import static org.openmetadata.service.jdbi3.locator.ConnectionType.MYSQL; +import static org.openmetadata.service.jdbi3.locator.ConnectionType.POSTGRES; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.UUID; +import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.statement.StatementContext; +import org.jdbi.v3.sqlobject.config.RegisterRowMapper; +import org.jdbi.v3.sqlobject.customizer.Bind; +import org.jdbi.v3.sqlobject.statement.SqlQuery; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.OntologyReleaseEvent; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.jdbi3.locator.ConnectionAwareSqlUpdate; +import org.openmetadata.service.ontology.OntologyJson; +import org.openmetadata.service.util.jdbi.BindJson; +import org.openmetadata.service.util.jdbi.BindUUID; + +public interface OntologyReleaseDAO extends EntityDAO { + @SqlQuery( + "SELECT id FROM ontology_release_entity WHERE ontologyId = :ontologyId LIMIT 1 FOR UPDATE") + String findRetainedReleaseForUpdate(@BindUUID("ontologyId") UUID ontologyId); + + default boolean hasReleases(final UUID ontologyId) { + // A purge must see captures committed after its MySQL transaction snapshot began. + return findRetainedReleaseForUpdate(ontologyId) != null; + } + + @SqlQuery( + "SELECT json FROM ontology_release_event WHERE releaseId = :releaseId " + + "AND eventType = 'ACTIVATED' ORDER BY createdAt, id LIMIT 1") + String findActivation(@BindUUID("releaseId") UUID releaseId); + + @Override + default String getTableName() { + return "ontology_release_entity"; + } + + @Override + default Class getEntityClass() { + return OntologyRelease.class; + } + + @Override + default String getNameHashColumn() { + return "fqnHash"; + } + + @ConnectionAwareSqlUpdate( + value = + "INSERT INTO ontology_release_member (releaseId, memberKind, memberId, sourceVersion, " + + "payload, payloadDigest, payloadBytes) " + + "SELECT :releaseId, :memberKind, :memberId, :sourceVersion, " + + ":payload, :payloadDigest, :payloadBytes FROM ontology_release_entity " + + "WHERE id = :releaseId AND sealedAt IS NULL", + connectionType = MYSQL) + @ConnectionAwareSqlUpdate( + value = + "INSERT INTO ontology_release_member (releaseId, memberKind, memberId, sourceVersion, " + + "payload, payloadDigest, payloadBytes) " + + "SELECT :releaseId, :memberKind, :memberId, :sourceVersion, " + + ":payload, :payloadDigest, :payloadBytes FROM ontology_release_entity " + + "WHERE id = :releaseId AND sealedAt IS NULL", + connectionType = POSTGRES) + int insertMember( + @BindUUID("releaseId") UUID releaseId, + @Bind("memberKind") String memberKind, + @BindUUID("memberId") UUID memberId, + @Bind("sourceVersion") Double sourceVersion, + @Bind("payload") String payload, + @Bind("payloadDigest") String payloadDigest, + @Bind("payloadBytes") long payloadBytes); + + default int insertMember(final OntologyReleaseMember member) { + return insertMember( + member.getReleaseId(), + member.getMemberKind().value(), + member.getMemberId(), + member.getSourceVersion(), + member.getPayload().toString(), + member.getPayloadDigest(), + member.getPayloadBytes()); + } + + @ConnectionAwareSqlUpdate( + value = + "UPDATE ontology_release_entity SET json = :json WHERE id = :id AND sealedAt IS NULL " + + "AND (SELECT COUNT(*) FROM ontology_release_member WHERE releaseId = :id) = :memberCount", + connectionType = MYSQL) + @ConnectionAwareSqlUpdate( + value = + "UPDATE ontology_release_entity SET json = :json::jsonb WHERE id = :id AND sealedAt IS NULL " + + "AND (SELECT COUNT(*) FROM ontology_release_member WHERE releaseId = :id) = :memberCount", + connectionType = POSTGRES) + int seal( + @BindUUID("id") UUID id, @BindJson("json") String json, @Bind("memberCount") int memberCount); + + @SqlQuery( + "SELECT m.* FROM ontology_release_member m JOIN ontology_release_entity r ON r.id = m.releaseId " + + "WHERE m.releaseId = :releaseId AND m.memberKind = :memberKind AND m.memberId > :after " + + "AND r.sealedAt IS NOT NULL ORDER BY m.memberId LIMIT :limit") + @RegisterRowMapper(MemberMapper.class) + List listMembers( + @BindUUID("releaseId") UUID releaseId, + @Bind("memberKind") String memberKind, + @Bind("after") String after, + @Bind("limit") int limit); + + @SqlQuery( + "SELECT m.* FROM ontology_release_member m JOIN ontology_release_entity r ON r.id = m.releaseId " + + "WHERE m.releaseId = :releaseId AND m.memberKind = :memberKind AND m.memberId = :memberId " + + "AND r.sealedAt IS NOT NULL") + @RegisterRowMapper(MemberMapper.class) + OntologyReleaseMember getMember( + @BindUUID("releaseId") UUID releaseId, + @Bind("memberKind") String memberKind, + @BindUUID("memberId") UUID memberId); + + @SqlQuery( + "SELECT COUNT(*) FROM ontology_release_member m JOIN ontology_release_entity r ON r.id = m.releaseId " + + "WHERE m.releaseId = :releaseId AND m.memberKind = :memberKind AND r.sealedAt IS NOT NULL") + int countMembers(@BindUUID("releaseId") UUID releaseId, @Bind("memberKind") String kind); + + @SqlQuery("SELECT json FROM ontology_release_event WHERE releaseId = :releaseId AND id = :id") + String findEvent(@BindUUID("releaseId") UUID releaseId, @BindUUID("id") UUID id); + + @SqlQuery( + "SELECT json FROM ontology_release_event WHERE releaseId = :releaseId " + + "AND (createdAt > :afterCreatedAt OR (createdAt = :afterCreatedAt AND id > :afterId)) " + + "ORDER BY createdAt, id LIMIT :limit") + List listEvents( + @BindUUID("releaseId") UUID releaseId, + @Bind("afterCreatedAt") long afterCreatedAt, + @Bind("afterId") String afterId, + @Bind("limit") int limit); + + @SqlQuery("SELECT COUNT(*) FROM ontology_release_event WHERE releaseId = :releaseId") + int countEvents(@BindUUID("releaseId") UUID releaseId); + + default void appendEvent(final OntologyReleaseEvent event) { + appendEvent( + event.getId(), + event.getOntologyId(), + event.getReleaseId(), + event.getEventType().value(), + event.getCreatedAt(), + event.getCreatedBy(), + JsonUtils.pojoToJson(event)); + } + + @ConnectionAwareSqlUpdate( + value = + "INSERT INTO ontology_release_event (id, ontologyId, releaseId, eventType, createdAt, createdBy, json) " + + "VALUES (:id, :ontologyId, :releaseId, :eventType, :createdAt, :createdBy, :json)", + connectionType = MYSQL) + @ConnectionAwareSqlUpdate( + value = + "INSERT INTO ontology_release_event (id, ontologyId, releaseId, eventType, createdAt, createdBy, json) " + + "VALUES (:id, :ontologyId, :releaseId, :eventType, :createdAt, :createdBy, :json::jsonb)", + connectionType = POSTGRES) + void appendEvent( + @BindUUID("id") UUID id, + @BindUUID("ontologyId") UUID ontologyId, + @BindUUID("releaseId") UUID releaseId, + @Bind("eventType") String eventType, + @Bind("createdAt") long createdAt, + @Bind("createdBy") String createdBy, + @BindJson("json") String json); + + class MemberMapper implements RowMapper { + @Override + public OntologyReleaseMember map(final ResultSet rows, final StatementContext context) + throws SQLException { + return new OntologyReleaseMember() + .withReleaseId(UUID.fromString(rows.getString("releaseId"))) + .withMemberKind(MemberKind.fromValue(rows.getString("memberKind"))) + .withMemberId(UUID.fromString(rows.getString("memberId"))) + .withSourceVersion(rows.getObject("sourceVersion", Double.class)) + .withPayload(OntologyJson.read(rows.getString("payload"))) + .withPayloadDigest(rows.getString("payloadDigest")) + .withPayloadBytes(rows.getLong("payloadBytes")); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReleaseRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReleaseRepository.java new file mode 100644 index 000000000000..42c8239ecdc2 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReleaseRepository.java @@ -0,0 +1,406 @@ +package org.openmetadata.service.jdbi3; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Supplier; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.governance.workflows.WorkflowDefinition; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.OntologyReleaseEvent; +import org.openmetadata.schema.type.OntologyReleaseEvent.EventType; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.type.OntologyReviewTarget; +import org.openmetadata.schema.type.change.ChangeSource; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.exception.EntityNotFoundException; +import org.openmetadata.service.exception.TaskStateConflictException; +import org.openmetadata.service.governance.workflows.OntologyWorkflowDecision; +import org.openmetadata.service.governance.workflows.WorkflowHandler; +import org.openmetadata.service.ontology.OntologyReview; +import org.openmetadata.service.util.PostCommitActionQueue; +import org.openmetadata.service.util.RestUtil; + +@Repository +public final class OntologyReleaseRepository extends OntologyEntityRepository { + private static final String USER_APPROVAL_TASK = "userApprovalTask"; + + public OntologyReleaseRepository() { + super( + Entity.ONTOLOGY_RELEASE, + OntologyRelease.class, + Entity.getCollectionDAO().ontologyReleaseDAO(), + "/v1/ontologyReleases/", + "releaseRevision,sourceWorkingRevision,ontologyIri,versionIri,encodingVersion,contentDigest,baseReleaseId,memberCount,totalBytes,sealedAt,sealedBy"); + } + + public void recordCapture(final OntologyRelease release) { + setInheritedFields(release, getFields("owners,reviewers")); + daoCollection + .ontologyReleaseDAO() + .appendEvent( + event(release, EventType.CAPTURED, release.getSealedBy(), release.getSealedAt())); + PostCommitActionQueue.runOrDefer(() -> postCreate(release)); + } + + public void recordDecision(final Task task, final OntologyWorkflowDecision decision) { + decision.requireTarget(task); + final OntologyRelease release = dao.findEntityById(task.getAbout().getId()); + final EventType type = + switch (decision.resolution()) { + case Approved -> EventType.APPROVED; + case Rejected -> EventType.REJECTED; + default -> throw new BadRequestException( + "A release decision requires a completed approval workflow"); + }; + final OntologyReleaseEvent event = + event(release, type, decision.user(), task.getResolution().getResolvedAt()) + .withTaskId(task.getId()) + .withWorkflowDefinitionId(task.getWorkflowDefinitionId()) + .withWorkflowInstanceId(task.getWorkflowInstanceId()) + .withReviewTarget(decision.target()) + .withVoters(decision.voters()) + .withResolution(task.getResolution()); + daoCollection.ontologyReleaseDAO().appendEvent(event); + } + + public ResultList listEvents( + final UUID releaseId, final int limit, final String after) { + final OntologyReleaseEvent cursor = after == null ? null : eventCursor(releaseId, after); + final List events = + daoCollection + .ontologyReleaseDAO() + .listEvents( + releaseId, + cursor == null ? -1 : cursor.getCreatedAt(), + cursor == null ? "" : cursor.getId().toString(), + limit + 1) + .stream() + .map(json -> JsonUtils.readValue(json, OntologyReleaseEvent.class)) + .toList(); + final List page = + List.copyOf(events.subList(0, Math.min(limit, events.size()))); + return new ResultList<>( + page, + null, + events.size() > limit ? page.getLast().getId().toString() : null, + daoCollection.ontologyReleaseDAO().countEvents(releaseId)); + } + + private OntologyReleaseEvent eventCursor(final UUID releaseId, final String after) { + final UUID id = UUID.fromString(RestUtil.decodeCursor(after)); + final String json = daoCollection.ontologyReleaseDAO().findEvent(releaseId, id); + if (json == null) { + throw new BadRequestException("The event cursor does not belong to this ontology release"); + } + return JsonUtils.readValue(json, OntologyReleaseEvent.class); + } + + private static OntologyReleaseEvent event( + final OntologyRelease release, final EventType type, final String user, final long time) { + return new OntologyReleaseEvent() + .withId(UUID.randomUUID()) + .withOntologyId(release.getOntology().getId()) + .withReleaseId(release.getId()) + .withEventType(type) + .withCreatedAt(time) + .withCreatedBy(user) + .withContentDigest(release.getContentDigest()); + } + + public OntologyReleaseMember getMember( + final UUID releaseId, final MemberKind kind, final UUID memberId) { + final OntologyReleaseMember member = + daoCollection.ontologyReleaseDAO().getMember(releaseId, kind.value(), memberId); + if (member == null) { + throw new EntityNotFoundException("Ontology release member not found: " + memberId); + } + return member; + } + + public ResultList listMembers( + final UUID releaseId, final MemberKind kind, final int limit, final String after) { + final String cursor = + after == null ? "" : UUID.fromString(RestUtil.decodeCursor(after)).toString(); + final List members = + daoCollection.ontologyReleaseDAO().listMembers(releaseId, kind.value(), cursor, limit + 1); + final List page = + List.copyOf(members.subList(0, Math.min(limit, members.size()))); + return new ResultList<>( + page, + null, + members.size() > limit ? page.getLast().getMemberId().toString() : null, + daoCollection.ontologyReleaseDAO().countMembers(releaseId, kind.value())); + } + + @Override + protected void validateDefinition(final OntologyRelease release) { + throw immutableContent(); + } + + @Override + protected EntityStatus initialEntityStatus(final OntologyRelease release) { + throw immutableContent(); + } + + @Override + public void storeEntity(final OntologyRelease release, final boolean update) { + throw immutableContent(); + } + + @Override + public EntityUpdater getUpdater( + final OntologyRelease original, + final OntologyRelease updated, + final Operation operation, + final ChangeSource source) { + throw immutableContent(); + } + + @Override + protected void preDelete(final OntologyRelease release, final String user) { + throw immutableContent(); + } + + @Override + protected R executeEntityWrite(final List releases, final Supplier work) { + return executeInTransaction( + () -> { + releases.stream() + .map(release -> release.getOntology().getId()) + .distinct() + .sorted() + .forEach( + id -> { + daoCollection.ontologyStateDAO().initialize(id); + daoCollection.ontologyStateDAO().lock(id); + }); + return work.get(); + }); + } + + public void prepareReview(final Task task) { + final OntologyRelease entity = reviewEntity(task); + requireAvailableReview(entity, task); + if (nullOrEmpty(entity.getReviewers())) { + throw new BadRequestException("Configure ontology reviewers before submitting for approval"); + } + requireReviewWorkflow(task); + task.setOntologyReview(OntologyReview.target(entity)); + task.setAbout(entity.getEntityReference()); + task.setAssignees(entity.getReviewers()); + task.setReviewers(entity.getReviewers()); + } + + public void startReview(final Task task) { + lockReviewTarget(task); + final OntologyRelease original = reviewEntity(task); + if (Objects.equals(original.getReviewTask(), task.getId())) { + return; + } + requireAvailableReview(original, task); + validateReviewTarget(original, task); + final OntologyRelease updated = JsonUtils.deepCopy(original, entityClass); + updated.setReviewTask(task.getId()); + updated.setEntityStatus(EntityStatus.IN_REVIEW); + persistWorkflowState(original, updated, task.getUpdatedBy()); + } + + private void requireAvailableReview(final OntologyRelease entity, final Task task) { + if (entity.getEntityStatus() == EntityStatus.APPROVED + || entity.getEntityStatus() == EntityStatus.REJECTED) { + throw TaskStateConflictException.of("A decided ontology release cannot be reviewed again"); + } + if (entity.getEntityStatus() == EntityStatus.IN_REVIEW + && entity.getReviewTask() != null + && !Objects.equals(entity.getReviewTask(), task.getId())) { + throw TaskStateConflictException.of("This ontology definition already has an open review"); + } + } + + public void validateReview(final Task task, final String user) { + final OntologyRelease entity = requireCurrentReview(task); + OntologyReview.requireIndependentReviewer(entity, task, user); + if (nullOrEmpty(entity.getReviewers())) { + throw new BadRequestException("Ontology reviewers are no longer configured"); + } + checkUpdatedByReviewer(entity, user); + } + + public OntologyRelease requireCurrentReview(final Task task) { + final OntologyRelease entity = reviewEntity(task); + validateReviewTarget(entity, task); + if (!Objects.equals(entity.getReviewTask(), task.getId())) { + throw new BadRequestException("This ontology review has been superseded"); + } + return entity; + } + + public void validateRuntimeReview(final Task task) { + final WorkflowHandler.ApprovalTaskContext context = + WorkflowHandler.getInstance().approvalTaskContext(task.getId()); + context.voters().forEach(user -> validateReview(task, user)); + } + + public void completeReview(final Task task, final String user) { + validateReview(task, user); + final EntityStatus decision = + switch (task.getStatus()) { + case Approved -> EntityStatus.APPROVED; + case Rejected -> EntityStatus.REJECTED; + default -> throw new BadRequestException( + "The approval workflow has not decided this review"); + }; + final OntologyRelease original = reviewEntity(task); + validateReviewTarget(original, task); + if (original.getEntityStatus() == decision) { + return; + } + final OntologyRelease updated = JsonUtils.deepCopy(original, entityClass); + updated.setEntityStatus(decision); + persistWorkflowState(original, updated, user); + } + + public void cancelReview(final Task task) { + final String json = lockCancellationTarget(task.getAbout().getId()); + if (json == null) { + return; + } + final OntologyRelease original = JsonUtils.readValue(json, entityClass); + if (Boolean.TRUE.equals(original.getDeleted()) + || original.getEntityStatus() != EntityStatus.IN_REVIEW + || !Objects.equals(original.getReviewTask(), task.getId())) { + return; + } + setFieldsInternal(original, getFields("owners,reviewers")); + setInheritedFields(original, getFields("owners,reviewers")); + final OntologyRelease updated = JsonUtils.deepCopy(original, entityClass); + updated.setReviewTask(null); + updated.setEntityStatus(EntityStatus.DRAFT); + persistWorkflowState(original, updated, task.getUpdatedBy()); + } + + private String lockCancellationTarget(final UUID id) { + final String json = dao.findById(dao.getTableName(), id, dao.getCondition(Include.ALL)); + if (json == null) { + return null; + } + // Ownership is immutable; discover it before locking to preserve module-before-definition + // order. + final OntologyRelease entity = JsonUtils.readValue(json, entityClass); + workingState.lock(entity.getOntology() == null ? entity.getId() : entity.getOntology().getId()); + return dao.findJsonByIdForUpdate(id, Include.ALL); + } + + public void lockReviewTarget(final Task task) { + final OntologyReviewTarget target = task.getOntologyReview(); + if (target == null + || target.getOntologyId() == null + || target.getEntityId() == null + || task.getAbout() == null + || !Objects.equals(target.getEntityId(), task.getAbout().getId())) { + throw new BadRequestException( + "An ontology review must identify its target and owning ontology"); + } + // Captured ownership avoids establishing a stale MySQL snapshot before the module lock. + lockActiveEntity(daoCollection.workflowDefinitionDAO(), task.getWorkflowDefinitionId()); + workingState.lock(target.getOntologyId()); + lockActiveEntity(daoCollection.ontologyEntityDAO(), target.getOntologyId()); + lockActiveEntity(dao, target.getEntityId()); + } + + private static void lockActiveEntity(final EntityDAO dao, final UUID id) { + dao.jsonToEntity(dao.findJsonByIdForUpdate(id, Include.NON_DELETED), id); + } + + private void persistWorkflowState( + final OntologyRelease original, final OntologyRelease updated, final String user) { + updated.setUpdatedBy(user); + updated.setUpdatedAt(System.currentTimeMillis()); + new ReleaseUpdater(original, updated).updateWithOptimisticLocking(); + } + + private OntologyRelease reviewEntity(final Task task) { + final UUID id = task.getAbout() == null ? null : task.getAbout().getId(); + if (id == null) { + throw new BadRequestException("An ontology approval task must identify its target"); + } + final OntologyRelease entity = + get(null, id, getFields("owners,reviewers"), Include.NON_DELETED, false); + if (entity.getOntology() != null) { + daoCollection + .ontologyEntityDAO() + .findEntityById(entity.getOntology().getId(), Include.NON_DELETED); + } + return entity; + } + + private void validateReviewTarget(final OntologyRelease entity, final Task task) { + OntologyReview.validateTarget(entity, task.getOntologyReview()); + requireRetainedReviewContent(entity, task.getOntologyReview()); + } + + private void requireRetainedReviewContent( + final OntologyRelease entity, final OntologyReviewTarget target) { + if (!Objects.equals(entity.getVersion(), target.getEntityVersion())) { + final OntologyRelease submitted = + getVersion(entity.getId(), target.getEntityVersion().toString()); + OntologyReview.validateTarget(submitted, target); + } + } + + private static WorkflowDefinition requireReviewWorkflow(final Task task) { + if (task.getWorkflowDefinitionId() == null || !WorkflowHandler.isInitialized()) { + throw new BadRequestException("An active approval workflow is required for ontology review"); + } + final WorkflowDefinitionRepository repository = + (WorkflowDefinitionRepository) Entity.getEntityRepository(Entity.WORKFLOW_DEFINITION); + final WorkflowDefinition workflow = + repository.get( + null, + task.getWorkflowDefinitionId(), + repository.getFields("*"), + Include.NON_DELETED, + false); + if (Boolean.TRUE.equals(workflow.getSuspended()) + || !WorkflowHandler.getInstance().isDeployed(workflow)) { + throw new BadRequestException("The ontology approval workflow is unavailable"); + } + if (listOrEmpty(workflow.getNodes()).stream() + .noneMatch(node -> USER_APPROVAL_TASK.equals(node.getSubType()))) { + throw new BadRequestException( + "Ontology review requires a workflow with a user approval task"); + } + return workflow; + } + + private class ReleaseUpdater extends EntityUpdater { + ReleaseUpdater(final OntologyRelease original, final OntologyRelease updated) { + super(original, updated, Operation.PUT, null); + setUseOptimisticLocking(true); + } + + @Override + void updateEntityStatus(final boolean consolidatingChanges) {} + + @Override + protected void entitySpecificUpdate(final boolean consolidatingChanges) { + recordChange("reviewTask", original.getReviewTask(), updated.getReviewTask()); + recordChange("entityStatus", original.getEntityStatus(), updated.getEntityStatus()); + } + } + + private static BadRequestException immutableContent() { + return new BadRequestException( + "Ontology releases are immutable; capture a new working revision"); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyRepository.java new file mode 100644 index 000000000000..1e97225d9d07 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyRepository.java @@ -0,0 +1,104 @@ +package org.openmetadata.service.jdbi3; + +import java.net.URI; +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.OntologyReference; +import org.openmetadata.schema.type.OntologyState; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.exception.PreconditionFailedException; +import org.openmetadata.service.jdbi3.CoreRelationshipDAOs.EntityRelationshipObject; +import org.openmetadata.service.jdbi3.CoreRelationshipDAOs.EntityRelationshipRecord; +import org.openmetadata.service.ontology.OntologyLayerValidator; +import org.openmetadata.service.util.FreshReadScope; + +@Repository +public class OntologyRepository extends OntologyEntityRepository { + public static final String COLLECTION_PATH = "/v1/ontologies/"; + + public OntologyRepository() { + super( + Entity.ONTOLOGY, + Ontology.class, + Entity.getCollectionDAO().ontologyEntityDAO(), + COLLECTION_PATH, + "iri,namespace,layer,prefixes"); + } + + @Override + protected void validateDefinition(final Ontology entity) { + requireAbsoluteIri(entity.getNamespace(), "namespace"); + OntologyLayerValidator.validatePrefixes(entity.getPrefixes()); + } + + @Override + protected List prepareChildrenForHardDeleteCascade( + final UUID parentId, final List children, final String updatedBy) { + requireNoRetainedReleases(parentId); + return children; + } + + @Override + protected List prepareChildrenForHardDeleteCascade( + final List parents, + final List children, + final String updatedBy) { + parents.forEach(parent -> requireNoRetainedReleases(parent.getId())); + return children; + } + + @Override + protected void entitySpecificCleanup(final Ontology entity) { + requireNoRetainedReleases(entity.getId()); + } + + private void requireNoRetainedReleases(final UUID id) { + if (daoCollection.ontologyReleaseDAO().hasReleases(id)) { + throw new BadRequestException( + "Ontology " + + id + + " has retained releases; archive it instead of permanently deleting it"); + } + } + + public R applyWorkingChanges( + final UUID id, final long expectedRevision, final Supplier work) { + return executeInTransaction( + () -> { + try (var ignored = FreshReadScope.enter()) { + final OntologyStateDAO states = daoCollection.ontologyStateDAO(); + states.initialize(id); + if (states.lock(id).getWorkingRevision() != expectedRevision) { + throw new PreconditionFailedException( + "Ontology changed since this draft was created"); + } + final Ontology root = + JsonUtils.readValue( + dao.findJsonByIdForUpdate(id, Include.NON_DELETED), Ontology.class); + return workingState.mutate(dao, List.of(root), work); + } + }); + } + + public OntologyState getState(final UUID id) { + final OntologyState state = daoCollection.ontologyStateDAO().get(id); + return state == null + ? new OntologyState() + .withOntologyId(id) + .withWorkingRevision(0L) + .withLastReleaseRevision(0L) + .withActivationRevision(0L) + : state; + } + + public ResultList listReferences( + final UUID id, final URI iri, final int limit, final String after) { + return new OntologyReferenceIndex(daoCollection).list(id, iri, limit, after); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyStateDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyStateDAO.java new file mode 100644 index 000000000000..3eb6d425a27f --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyStateDAO.java @@ -0,0 +1,79 @@ +package org.openmetadata.service.jdbi3; + +import static org.openmetadata.service.jdbi3.locator.ConnectionType.MYSQL; +import static org.openmetadata.service.jdbi3.locator.ConnectionType.POSTGRES; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.UUID; +import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.statement.StatementContext; +import org.jdbi.v3.sqlobject.config.RegisterRowMapper; +import org.jdbi.v3.sqlobject.customizer.Bind; +import org.jdbi.v3.sqlobject.statement.SqlQuery; +import org.jdbi.v3.sqlobject.statement.SqlUpdate; +import org.openmetadata.schema.type.OntologyState; +import org.openmetadata.service.jdbi3.locator.ConnectionAwareSqlUpdate; +import org.openmetadata.service.util.jdbi.BindUUID; + +public interface OntologyStateDAO { + @ConnectionAwareSqlUpdate( + value = + "INSERT INTO ontology_state (ontologyId) VALUES (:ontologyId) " + + "ON DUPLICATE KEY UPDATE ontologyId = ontologyId", + connectionType = MYSQL) + @ConnectionAwareSqlUpdate( + value = "INSERT INTO ontology_state (ontologyId) VALUES (:ontologyId) ON CONFLICT DO NOTHING", + connectionType = POSTGRES) + void initialize(@BindUUID("ontologyId") UUID ontologyId); + + @SqlQuery("SELECT * FROM ontology_state WHERE ontologyId = :ontologyId") + @RegisterRowMapper(StateMapper.class) + OntologyState get(@BindUUID("ontologyId") UUID ontologyId); + + @SqlQuery("SELECT * FROM ontology_state WHERE ontologyId = :ontologyId FOR UPDATE") + @RegisterRowMapper(StateMapper.class) + OntologyState lock(@BindUUID("ontologyId") UUID ontologyId); + + @SqlQuery( + "SELECT s.ontologyId FROM ontology_state s JOIN ontology_entity o ON o.id = s.ontologyId " + + "WHERE s.activeReleaseId IS NOT NULL AND o.deleted = FALSE AND s.ontologyId > :after " + + "ORDER BY s.ontologyId LIMIT :limit") + List listActive(@Bind("after") String after, @Bind("limit") int limit); + + @SqlUpdate( + "UPDATE ontology_state SET workingRevision = workingRevision + 1 WHERE ontologyId = :ontologyId") + int advanceWorkingRevision(@BindUUID("ontologyId") UUID ontologyId); + + @SqlUpdate( + "UPDATE ontology_state SET lastReleaseRevision = lastReleaseRevision + 1 " + + "WHERE ontologyId = :ontologyId AND lastReleaseRevision = :expectedRevision") + int allocateReleaseRevision( + @BindUUID("ontologyId") UUID ontologyId, @Bind("expectedRevision") long expectedRevision); + + @SqlUpdate( + "UPDATE ontology_state SET activeReleaseId = :releaseId, activationRevision = activationRevision + 1 " + + "WHERE ontologyId = :ontologyId AND activationRevision = :expectedRevision " + + "AND EXISTS (SELECT 1 FROM ontology_release_entity r WHERE r.id = :releaseId " + + "AND r.ontologyId = :ontologyId AND r.sealedAt IS NOT NULL " + + "AND COALESCE(r.deleted, false) = false AND r.entityStatus = 'Approved')") + int activate( + @BindUUID("ontologyId") UUID ontologyId, + @BindUUID("releaseId") UUID releaseId, + @Bind("expectedRevision") long expectedRevision); + + class StateMapper implements RowMapper { + @Override + public OntologyState map(final ResultSet rows, final StatementContext context) + throws SQLException { + final String activeRelease = rows.getString("activeReleaseId"); + return new OntologyState() + .withOntologyId(UUID.fromString(rows.getString("ontologyId"))) + .withWorkingRevision(rows.getLong("workingRevision")) + .withLastReleaseRevision(rows.getLong("lastReleaseRevision")) + .withActiveReleaseId(activeRelease == null ? null : UUID.fromString(activeRelease)) + .withActivationRevision(rows.getLong("activationRevision")); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyWorkingModel.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyWorkingModel.java new file mode 100644 index 000000000000..c06fc2595180 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyWorkingModel.java @@ -0,0 +1,239 @@ +package org.openmetadata.service.jdbi3; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.apache.commons.codec.digest.DigestUtils; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.OntologyEntityInterface; +import org.openmetadata.schema.api.data.OntologyProfileReport; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.ontology.OntologyClassHierarchy; +import org.openmetadata.service.ontology.OntologyReferences; +import org.openmetadata.service.ontology.OntologyReleaseManifest; +import org.openmetadata.service.ontology.OntologyReleaseReader; +import org.openmetadata.service.ontology.OntologyStructureValidator; + +/** Reads only affected definitions and their semantic context under the ontology's write lock. */ +final class OntologyWorkingModel { + private static final List STRUCTURE = + List.of( + "iri", + "deleted", + "conceptType", + "conceptMappings", + "kind", + "domain", + "range", + "inverse", + "propertyChain", + "disjointWith", + "characteristics", + "enumValues", + "axiomType", + "subjectIri", + "expressions", + "propertyIri", + "targetIri", + "literal"); + private static final int LIMIT = OntologyReleaseManifest.MAX_MEMBERS; + private final CollectionDAO dao; + + record Change(OntologyDefinition before, OntologyDefinition after) {} + + OntologyWorkingModel(final CollectionDAO dao) { + this.dao = dao; + } + + void requireValid(final UUID ontologyId, final List changes) { + final var context = new Context(ontologyId, true); + for (final var change : changes) { + if (!structure(change.before()).equals(structure(change.after()))) { + if (change.after() != null) { + context.add(change.after(), true); + } + if (change.before() instanceof OntologyEntityInterface declaration) { + context.dependents(Set.of(declaration.getIri().toString())); + } else if (change.after() instanceof OntologyEntityInterface declaration) { + context.dependents(Set.of(declaration.getIri().toString())); + } + } + } + context.expand(); + OntologyStructureValidator.requireValidWorking(context.values(), context.affected); + } + + OntologyProfileReport validate(final OntologyAxiom proposed) { + final var context = new Context(proposed.getOntology().getId(), false); + context.add(proposed, true); + context.expand(); + return OntologyStructureValidator.validateWorking(context.values(), context.affected); + } + + private static ObjectNode structure(final OntologyDefinition value) { + return value == null || value instanceof Ontology + ? JsonUtils.getObjectMapper().createObjectNode() + : ((ObjectNode) JsonUtils.getObjectMapper().valueToTree(value)).retain(STRUCTURE); + } + + private final class Context { + private final UUID ontologyId; + private final String locking; + private final Map definitions = new LinkedHashMap<>(); + private final Set affected = new HashSet<>(); + private final Set dependentIris = new HashSet<>(); + private final Set declaredIris = new HashSet<>(); + private final Set ancestorIris = new HashSet<>(); + private long bytes; + + Context(final UUID ontologyId, final boolean forUpdate) { + this.ontologyId = ontologyId; + this.locking = forUpdate ? "FOR UPDATE" : ""; + } + + List values() { + return List.copyOf(definitions.values()); + } + + void add(final OntologyDefinition value, final boolean validate) { + if (Boolean.TRUE.equals(value.getDeleted()) || value instanceof Ontology) { + return; + } + if (validate) { + affected.add(value.getId()); + } + if (definitions.putIfAbsent(value.getId(), value) == null) { + final int size = JsonUtils.pojoToJson(value).getBytes(StandardCharsets.UTF_8).length; + if (size > OntologyReleaseManifest.MAX_MEMBER_BYTES) { + throw new BadRequestException("An ontology definition exceeds the 1 MiB member limit"); + } + bytes += size; + OntologyReleaseReader.requireBounds(definitions.size(), bytes); + } + } + + void dependents(final Set iris) { + final var pending = new HashSet<>(iris); + while (!pending.isEmpty()) { + pending.removeAll(dependentIris); + if (pending.isEmpty()) { + return; + } + dependentIris.addAll(pending); + requireBound(dependentIris.size()); + final var references = + dao.ontologyReferenceDAO() + .referencingForUpdate( + ontologyId, pending.stream().map(DigestUtils::sha256Hex).toList(), LIMIT + 1); + requireBound(references.size()); + final var next = new HashSet(); + for (final var reference : references) { + if (!pending.contains(reference.getIri().toString())) { + continue; + } + final var source = reference.getSource(); + if (affected.contains(source.getId())) { + continue; + } + final EntityDAO entities = + switch (source.getType()) { + case Entity.ONTOLOGY_CONCEPT -> dao.ontologyConceptDAO(); + case Entity.ONTOLOGY_PROPERTY -> dao.ontologyPropertyDAO(); + case Entity.ONTOLOGY_AXIOM -> dao.ontologyAxiomDAO(); + default -> throw new BadRequestException("Unsupported ontology reference source"); + }; + final String json = entities.findJsonByIdForUpdate(source.getId(), Include.NON_DELETED); + if (json != null) { + final var value = JsonUtils.readValue(json, entities.getEntityClass()); + add(value, true); + if (value instanceof OntologyEntityInterface declaration) { + next.add(declaration.getIri().toString()); + } + } + } + pending.clear(); + pending.addAll(next); + } + } + + void expand() { + final Set expanded = new HashSet<>(); + while (expanded.size() < definitions.size()) { + final var iris = new HashSet(); + final var parents = new HashSet(); + for (final var value : values()) { + if (expanded.add(value.getId())) { + OntologyReferences.collect(value).forEach(ref -> iris.add(ref.getIri().toString())); + if (value instanceof OntologyProperty property) { + iris.add(property.getIri().toString()); + } + if (value instanceof OntologyAxiom axiom) { + OntologyClassHierarchy.namedParents(axiom).stream() + .map(URI::toString) + .forEach(parents::add); + } + } + } + iris.removeAll(declaredIris); + parents.removeAll(ancestorIris); + declaredIris.addAll(iris); + ancestorIris.addAll(parents); + requireBound(declaredIris.size()); + if (!iris.isEmpty()) { + final var hashes = iris.stream().map(DigestUtils::md5Hex).toList(); + for (final var entities : List.of(dao.ontologyConceptDAO(), dao.ontologyPropertyDAO())) { + final var rows = + entities.findDeclarations( + entities.getTableName(), ontologyId, hashes, LIMIT + 1, locking); + requireBound(rows.size()); + rows.stream() + .map(json -> JsonUtils.readValue(json, entities.getEntityClass())) + .forEach(value -> add(value, false)); + } + final var inverses = + dao.ontologyReferenceDAO() + .incomingInverses( + ontologyId, + iris.stream().map(DigestUtils::sha256Hex).toList(), + LIMIT + 1, + locking); + requireBound(inverses.size()); + inverses.stream() + .map(json -> JsonUtils.readValue(json, OntologyProperty.class)) + .forEach(value -> add(value, false)); + } + if (!parents.isEmpty()) { + final var rows = + dao.ontologyReferenceDAO() + .ancestors( + ontologyId, + parents.stream().map(DigestUtils::sha256Hex).toList(), + LIMIT + 1, + locking); + requireBound(rows.size()); + rows.stream() + .map(json -> JsonUtils.readValue(json, OntologyAxiom.class)) + .forEach(value -> add(value, false)); + } + } + } + } + + private static void requireBound(final int size) { + if (size > LIMIT) { + throw new BadRequestException("Ontology validation exceeds the 10000 reference limit"); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyWorkingState.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyWorkingState.java new file mode 100644 index 000000000000..f6df3eacd033 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyWorkingState.java @@ -0,0 +1,145 @@ +package org.openmetadata.service.jdbi3; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.PreconditionFailedException; +import org.openmetadata.service.rdf.RdfUpdater; + +/** Serializes a module's SQL writes with release capture on the caller's retained transaction. */ +final class OntologyWorkingState { + private static final ThreadLocal CHANGES = new ThreadLocal<>(); + private final CollectionDAO dao; + private final OntologyStateDAO states; + private final OntologyReferenceIndex references; + private final OntologyWorkingModel model; + + OntologyWorkingState(final CollectionDAO dao) { + this.dao = dao; + states = dao.ontologyStateDAO(); + references = new OntologyReferenceIndex(dao); + model = new OntologyWorkingModel(dao); + } + + R mutate( + final EntityDAO entities, final List definitions, final Supplier work) { + final boolean outermost = CHANGES.get() == null; + if (outermost) { + CHANGES.set(new Changes(new HashMap<>(), new HashSet<>())); + } + try { + final R result = mutateLocked(entities, definitions, work); + if (outermost) { + CHANGES.get().definitions().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> model.requireValid(entry.getKey(), entry.getValue())); + CHANGES.get().graphs().stream() + .sorted() + .forEach(id -> RdfUpdater.enqueueOntologyGraph(dao, id)); + } + return result; + } finally { + if (outermost) { + CHANGES.remove(); + } + } + } + + private R mutateLocked( + final EntityDAO entities, final List definitions, final Supplier work) { + definitions.stream() + .map(OntologyWorkingState::ontologyId) + .distinct() + .sorted() + .forEach(this::lock); + final List baselines = + definitions.stream() + .sorted(Comparator.comparing(OntologyDefinition::getId)) + .map(entity -> lockCurrentRevision(entities, entity)) + .toList(); + final R result = work.get(); + final var changed = updateReferences(entities, baselines); + changed.forEach(states::advanceWorkingRevision); + return result; + } + + private Set updateReferences( + final EntityDAO entities, final List baselines) { + final Set changed = new HashSet<>(); + for (final Baseline baseline : baselines) { + final String json = entities.findJsonByIdForUpdate(baseline.entityId(), Include.ALL); + if (!Objects.equals(baseline.json(), json)) { + final T current = + json == null ? null : JsonUtils.readValue(json, entities.getEntityClass()); + references.replace( + Entity.getEntityTypeFromClass(entities.getEntityClass()), baseline.entityId(), current); + CHANGES + .get() + .definitions() + .computeIfAbsent(baseline.ontologyId(), ignored -> new ArrayList<>()) + .add( + new OntologyWorkingModel.Change( + baseline.json() == null + ? null + : JsonUtils.readValue(baseline.json(), entities.getEntityClass()), + current)); + changed.add(baseline.ontologyId()); + if (entities.getEntityClass() == Ontology.class + && baseline.json() != null + && (json == null || isDeleted(baseline.json()) != isDeleted(json))) { + CHANGES.get().graphs().add(baseline.ontologyId()); + } + } + } + return changed; + } + + void lock(final UUID ontologyId) { + states.initialize(ontologyId); + states.lock(ontologyId); + } + + private static Baseline lockCurrentRevision( + final EntityDAO entities, final T expected) { + // Lock existing rows only: locking a missing MySQL key blocks unrelated inserts in its gap. + final String existing = + entities.findById( + entities.getTableName(), expected.getId(), entities.getCondition(Include.ALL)); + final String json = + existing == null ? null : entities.findJsonByIdForUpdate(expected.getId(), Include.ALL); + if (json != null) { + final T current = JsonUtils.readValue(json, entities.getEntityClass()); + if (!Objects.equals(expected.getVersion(), current.getVersion()) + || !Objects.equals(expected.getUpdatedAt(), current.getUpdatedAt())) { + throw new PreconditionFailedException( + "Ontology content changed before this write: " + expected.getFullyQualifiedName()); + } + } + return new Baseline(ontologyId(expected), expected.getId(), json); + } + + private static UUID ontologyId(final OntologyDefinition entity) { + return entity.getOntology() == null ? entity.getId() : entity.getOntology().getId(); + } + + private static boolean isDeleted(final String json) { + return JsonUtils.readTree(json).path("deleted").asBoolean(); + } + + private record Changes( + Map> definitions, Set graphs) {} + + private record Baseline(UUID ontologyId, UUID entityId, String json) {} +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/RdfInfraDAOs.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/RdfInfraDAOs.java index ec8fceb7cd1a..8b8657f78d47 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/RdfInfraDAOs.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/RdfInfraDAOs.java @@ -26,11 +26,17 @@ import org.jdbi.v3.sqlobject.config.RegisterRowMapper; import org.jdbi.v3.sqlobject.customizer.Bind; import org.jdbi.v3.sqlobject.customizer.BindBean; +import org.jdbi.v3.sqlobject.customizer.BindList; +import org.jdbi.v3.sqlobject.customizer.Define; import org.jdbi.v3.sqlobject.statement.SqlBatch; import org.jdbi.v3.sqlobject.statement.SqlQuery; import org.jdbi.v3.sqlobject.statement.SqlUpdate; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.entity.data.Ontology; import org.openmetadata.schema.entity.data.OntologyAxiom; import org.openmetadata.schema.entity.data.OntologyChangeSet; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; import org.openmetadata.schema.entity.data.RelationshipType; import org.openmetadata.service.jdbi3.locator.ConnectionAwareSqlQuery; import org.openmetadata.service.jdbi3.locator.ConnectionAwareSqlUpdate; @@ -40,6 +46,24 @@ public interface RdfInfraDAOs { @CreateSqlObject OntologyDAO ontologyDAO(); + @CreateSqlObject + OntologyEntityDAO ontologyEntityDAO(); + + @CreateSqlObject + OntologyConceptDAO ontologyConceptDAO(); + + @CreateSqlObject + OntologyPropertyDAO ontologyPropertyDAO(); + + @CreateSqlObject + OntologyStateDAO ontologyStateDAO(); + + @CreateSqlObject + OntologyReferenceDAO ontologyReferenceDAO(); + + @CreateSqlObject + OntologyReleaseDAO ontologyReleaseDAO(); + @CreateSqlObject RelationshipTypeDAO relationshipTypeDAO(); @@ -89,6 +113,83 @@ record OntologyEditLockRow( long renewedAt, long expiresAt) {} + interface OntologyEntityDAO extends EntityDAO { + @Override + default String getTableName() { + return "ontology_entity"; + } + + @Override + default Class getEntityClass() { + return Ontology.class; + } + + @Override + default String getNameHashColumn() { + return "fqnHash"; + } + } + + interface OntologyMemberDAO extends EntityDAO { + @SqlQuery( + "SELECT json FROM WHERE ontologyId = :ontologyId AND deleted = FALSE " + + "AND iriHash IN () ORDER BY name, id LIMIT :limit ") + List findDeclarations( + @Define("table") String table, + @BindUUID("ontologyId") UUID ontologyId, + @BindList("iriHashes") List iriHashes, + @Bind("limit") int limit, + @Define("locking") String locking); + } + + interface OntologyConceptDAO extends OntologyMemberDAO { + @Override + default String getTableName() { + return "ontology_concept_entity"; + } + + @Override + default Class getEntityClass() { + return OntologyConcept.class; + } + + @Override + default String getNameHashColumn() { + return "fqnHash"; + } + } + + interface OntologyPropertyDAO extends OntologyMemberDAO { + @ConnectionAwareSqlQuery( + value = + "SELECT json FROM ontology_property_entity WHERE ontologyId = :ontologyId " + + "AND iriHash = md5(:iri) " + + "AND BINARY JSON_UNQUOTE(JSON_EXTRACT(json, '$.iri')) = BINARY :iri " + + "AND deleted = FALSE", + connectionType = MYSQL) + @ConnectionAwareSqlQuery( + value = + "SELECT json FROM ontology_property_entity WHERE ontologyId = :ontologyId " + + "AND iriHash = md5(:iri) AND json->>'iri' = :iri AND deleted = FALSE", + connectionType = POSTGRES) + String findByIri(@BindUUID("ontologyId") UUID ontologyId, @Bind("iri") String iri); + + @Override + default String getTableName() { + return "ontology_property_entity"; + } + + @Override + default Class getEntityClass() { + return OntologyProperty.class; + } + + @Override + default String getNameHashColumn() { + return "fqnHash"; + } + } + interface RelationshipTypeDAO extends EntityDAO { @Override default String getTableName() { @@ -122,7 +223,7 @@ default String getNameHashColumn() { List listActive(); } - interface OntologyAxiomDAO extends EntityDAO { + interface OntologyAxiomDAO extends OntologyMemberDAO { @Override default String getTableName() { return "ontology_axiom_entity"; @@ -137,34 +238,6 @@ default Class getEntityClass() { default String getNameHashColumn() { return "fqnHash"; } - - @ConnectionAwareSqlQuery( - value = - "SELECT COUNT(*) FROM ontology_axiom_entity " - + "WHERE JSON_UNQUOTE(JSON_EXTRACT(json, '$.subjectIri')) = :iri " - + "AND axiomType IN ('SUBCLASS_OF', 'EQUIVALENT_CLASS', 'DISJOINT_WITH') " - + "AND deleted = FALSE", - connectionType = MYSQL) - @ConnectionAwareSqlQuery( - value = - "SELECT COUNT(*) FROM ontology_axiom_entity WHERE json->>'subjectIri' = :iri " - + "AND axiomType IN ('SUBCLASS_OF', 'EQUIVALENT_CLASS', 'DISJOINT_WITH') " - + "AND deleted = FALSE", - connectionType = POSTGRES) - int countClassSubjects(@Bind("iri") String iri); - - @ConnectionAwareSqlQuery( - value = - "SELECT COUNT(*) FROM ontology_axiom_entity " - + "WHERE JSON_UNQUOTE(JSON_EXTRACT(json, '$.subjectIri')) = :iri " - + "AND axiomType = 'CLASS_ASSERTION' AND deleted = FALSE", - connectionType = MYSQL) - @ConnectionAwareSqlQuery( - value = - "SELECT COUNT(*) FROM ontology_axiom_entity WHERE json->>'subjectIri' = :iri " - + "AND axiomType = 'CLASS_ASSERTION' AND deleted = FALSE", - connectionType = POSTGRES) - int countIndividualSubjects(@Bind("iri") String iri); } interface OntologyChangeSetDAO extends EntityDAO { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TaskRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TaskRepository.java index ef7cf5f190db..c06da0fcaa91 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TaskRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TaskRepository.java @@ -66,6 +66,7 @@ import org.openmetadata.service.exception.EntityNotFoundException; import org.openmetadata.service.governance.approval.ChangeRequestReview; import org.openmetadata.service.governance.approval.ChangeRequestTasks; +import org.openmetadata.service.governance.workflows.OntologyApprovalWorkflow; import org.openmetadata.service.governance.workflows.WorkflowHandler; import org.openmetadata.service.jdbi3.CoreRelationshipDAOs.FieldRelationshipDAO.FieldRelationship; import org.openmetadata.service.resources.feeds.MessageParser; @@ -114,6 +115,7 @@ public class TaskRepository extends EntityRepository { // Stage a workflow-managed task holds after being persisted but before its Flowable instance // starts. public static final String PENDING_WORKFLOW_START_STAGE_ID = "pending-workflow-start"; + public static final String WORKFLOW_START_FAILED_STAGE_ID = "workflow-start-failed"; private static final Set ALREADY_OPEN_STATUSES = Set.of(TaskEntityStatus.Open, TaskEntityStatus.InProgress); @@ -389,6 +391,7 @@ public void prepare(Task task, boolean update) { computeAboutFqnHash(task); initializeWorkflowManagedTask(task, update); + OntologyApprovalWorkflow.prepare(task, update); // Task domains MUST be inherited from the target entity (about field) // This ensures tasks follow domain-based data isolation policies @@ -587,8 +590,16 @@ private List extractDomainsFromEntity(Object entity) { return null; } + @Override + protected List getFieldsStrippedFromStorageJson() { + return List.of(FIELD_ABOUT, FIELD_CREATED_BY, FIELD_ASSIGNEES, FIELD_WATCHERS); + } + @Override public void storeEntity(Task task, boolean update) { + if (!update && OntologyApprovalWorkflow.isReview(task)) { + OntologyApprovalWorkflow.lockTarget(task); + } List domains = task.getDomains(); EntityReference about = task.getAbout(); EntityReference createdBy = task.getCreatedBy(); @@ -657,6 +668,12 @@ public void storeRelationships(Task task) { Entity.TASK, Relationship.MENTIONED_IN); } + OntologyApprovalWorkflow.start(task); + } + + @Override + protected void preDelete(Task task, String deletedBy) { + OntologyApprovalWorkflow.requireDeletable(task); } private void storeAssignees(Task task) { @@ -1339,6 +1356,11 @@ public Task resolveTask( throw new IllegalArgumentException("Resolution cannot be null"); } Task original = get(null, task.getId(), getFields("*")); + if (OntologyApprovalWorkflow.isReview(original) + && original.getStatus() == TaskEntityStatus.Cancelled + && resolution.getType() == TaskResolutionType.Cancelled) { + return original; + } Task updated = JsonUtils.deepCopy(original, Task.class); updated.setStatus(mapResolutionToStatus(resolution.getType())); updated.setResolution(resolution); @@ -1566,6 +1588,19 @@ protected void postUpdate(Task original, Task updated) { IncidentTcrsSyncHandler.sync(updated); } + @Override + protected void flushInOneTransaction(final List tasks, final Runnable work) { + if (tasks.stream().anyMatch(OntologyApprovalWorkflow::isReview)) { + executeInTransaction( + () -> { + work.run(); + return null; + }); + } else { + super.flushInOneTransaction(tasks, work); + } + } + private void initializeWorkflowManagedTask(Task task, boolean update) { if (update || !shouldCreateWorkflowManagedTask(task)) { return; @@ -1653,7 +1688,7 @@ private boolean triggerWorkflowManagedTask(Task task) { private void markWorkflowTriggerFailure(Task task) { try { - task.setWorkflowStageId("workflow-start-failed"); + task.setWorkflowStageId(WORKFLOW_START_FAILED_STAGE_ID); task.setWorkflowStageDisplayName("Workflow start failed"); task.setUpdatedAt(System.currentTimeMillis()); storeEntity(task, true); @@ -1759,10 +1794,13 @@ public TaskUpdater( Operation operation, org.openmetadata.schema.type.change.ChangeSource changeSource) { super(original, updated, operation, changeSource); + setUseOptimisticLocking(OntologyApprovalWorkflow.isReview(original)); } @Override public void entitySpecificUpdate(boolean consolidatingChanges) { + OntologyApprovalWorkflow.requireUnchangedTarget(original, updated); + OntologyApprovalWorkflow.cancel(updated); preserveComments(); updateAssignees(); updateTaskReviewers(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/NativeOntologyChangeExecutor.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/NativeOntologyChangeExecutor.java new file mode 100644 index 000000000000..d15610adee21 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/NativeOntologyChangeExecutor.java @@ -0,0 +1,103 @@ +package org.openmetadata.service.ontology; + +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.core.UriInfo; +import java.time.Clock; +import java.util.function.Supplier; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyChangeSet; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.OntologyChangeOperation; +import org.openmetadata.schema.type.Relationship; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.OntologyEntityRepository; +import org.openmetadata.service.jdbi3.OntologyRepository; +import org.openmetadata.service.ontology.OntologyChangeOperationExecutor.OperationOutcome; + +public final class NativeOntologyChangeExecutor { + private final OntologyRepository ontologies; + private final OntologyEntityRepository concepts; + private final OntologyEntityRepository properties; + private final OntologyEntityRepository axioms; + private final Clock clock; + + public NativeOntologyChangeExecutor( + final OntologyRepository ontologies, + final OntologyEntityRepository concepts, + final OntologyEntityRepository properties, + final OntologyEntityRepository axioms, + final Clock clock) { + this.ontologies = ontologies; + this.concepts = concepts; + this.properties = properties; + this.axioms = axioms; + this.clock = clock; + } + + public R inScope(final OntologyChangeSet changeSet, final Supplier work) { + return ontologies.applyWorkingChanges( + changeSet.getOntology().getId(), changeSet.getBaseWorkingRevision(), work); + } + + public OperationOutcome execute( + final UriInfo uriInfo, final String user, final OntologyChangeOperation operation) { + return switch (operation.getOperationType()) { + case UPSERT_CONCEPT -> upsert(concepts, operation, uriInfo, user); + case UPSERT_PROPERTY -> upsert(properties, operation, uriInfo, user); + case UPSERT_AXIOM -> upsert(axioms, operation, uriInfo, user); + case DELETE_CONCEPT -> outcome( + concepts.delete(user, operation.getTargetId(), false, false).entity()); + case DELETE_PROPERTY -> outcome( + properties.delete(user, operation.getTargetId(), false, false).entity()); + case DELETE_AXIOM -> outcome( + axioms.delete(user, operation.getTargetId(), false, false).entity()); + default -> throw new BadRequestException("Unsupported native definition operation"); + }; + } + + private OperationOutcome upsert( + final OntologyEntityRepository repository, + final OntologyChangeOperation operation, + final UriInfo uriInfo, + final String user) { + final T definition = + JsonUtils.deepCopy( + NativeOntologyChanges.definition(operation), repository.getEntityClass()); + final boolean update = operation.getTargetId() != null; + definition.setUpdatedBy(user); + definition.setUpdatedAt(clock.millis()); + repository.prepareInternal(definition, update); + if (!update + && repository + .getByNameOrNull( + null, + definition.getFullyQualifiedName(), + repository.getFields(""), + Include.ALL, + false) + .isPresent()) { + throw new BadRequestException( + "Definition already exists: " + definition.getFullyQualifiedName()); + } + final T saved = repository.createOrUpdate(uriInfo, definition, user).getEntity(); + if (operation.getSourceMemoryIds() != null) { + for (final var memoryId : operation.getSourceMemoryIds()) { + repository.addRelationship( + saved.getId(), + memoryId, + NativeOntologyChanges.entityType(operation), + Entity.CONTEXT_MEMORY, + Relationship.DERIVED_FROM); + } + } + return outcome(saved); + } + + private static OperationOutcome outcome(final OntologyDefinition definition) { + return new OperationOutcome(definition.getEntityReference(), definition.getVersion()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/NativeOntologyChanges.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/NativeOntologyChanges.java new file mode 100644 index 000000000000..8bad9f81674b --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/NativeOntologyChanges.java @@ -0,0 +1,132 @@ +package org.openmetadata.service.ontology; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; + +import jakarta.ws.rs.BadRequestException; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.entity.data.OntologyChangeSet; +import org.openmetadata.schema.type.OntologyChangeOperation; +import org.openmetadata.schema.type.OntologyChangeOperationState; +import org.openmetadata.service.Entity; + +/** Typed operations share the memory draft lifecycle, but never the glossary model. */ +public final class NativeOntologyChanges { + private NativeOntologyChanges() {} + + public static String entityType(final OntologyChangeOperation operation) { + return switch (operation.getOperationType()) { + case UPSERT_CONCEPT, DELETE_CONCEPT -> Entity.ONTOLOGY_CONCEPT; + case UPSERT_PROPERTY, DELETE_PROPERTY -> Entity.ONTOLOGY_PROPERTY; + case UPSERT_AXIOM, DELETE_AXIOM -> Entity.ONTOLOGY_AXIOM; + default -> throw new BadRequestException( + "Native ontology drafts require definition operations"); + }; + } + + public static boolean isDelete(final OntologyChangeOperation operation) { + return switch (operation.getOperationType()) { + case DELETE_CONCEPT, DELETE_PROPERTY, DELETE_AXIOM -> true; + default -> false; + }; + } + + public static OntologyDefinition definition(final OntologyChangeOperation operation) { + return switch (operation.getOperationType()) { + case UPSERT_CONCEPT -> operation.getConcept(); + case UPSERT_PROPERTY -> operation.getProperty(); + case UPSERT_AXIOM -> operation.getAxiom(); + default -> null; + }; + } + + static void validate(final OntologyChangeSet changeSet, final int cursor) { + if (!listOrEmpty(changeSet.getGlossaries()).isEmpty() + || !Entity.ONTOLOGY.equals(changeSet.getOntology().getType()) + || changeSet.getOntology().getId() == null + || changeSet.getBaseWorkingRevision() == null + || changeSet.getBaseWorkingRevision() < 0) { + throw new BadRequestException( + "Native drafts require one ontology and its baseWorkingRevision"); + } + final var operations = listOrEmpty(changeSet.getOperations()); + if (operations.size() > 1000) { + throw new BadRequestException("A native draft accepts at most 1000 operations"); + } + final Set operationIds = new HashSet<>(); + final Set definitionIds = new HashSet<>(); + for (int index = 0; index < operations.size(); index++) { + final OntologyChangeOperation operation = operations.get(index); + if (operation == null + || operation.getId() == null + || operation.getOperationType() == null + || !operationIds.add(operation.getId())) { + throw new BadRequestException("Every ontology operation requires a unique id and type"); + } + entityType(operation); + validatePayload(changeSet, operation); + final UUID id = + operation.getTargetId() == null ? definition(operation).getId() : operation.getTargetId(); + if (!definitionIds.add(id)) { + throw new BadRequestException("A native draft may change each definition only once"); + } + operation.setState( + index < cursor + ? OntologyChangeOperationState.ACTIVE + : OntologyChangeOperationState.UNDONE); + } + } + + private static void validatePayload( + final OntologyChangeSet changeSet, final OntologyChangeOperation operation) { + final OntologyDefinition definition = definition(operation); + final int count = OntologyChangeSetValidator.payloadCount(operation); + if (isDelete(operation)) { + if (count != 0 || operation.getTargetId() == null) { + throw new BadRequestException("A definition delete requires a targetId and no payload"); + } + } else { + if (count != 1 + || definition == null + || definition.getId() == null + || definition.getName() == null + || definition.getName().isBlank() + || definition.getOntology() == null + || !Entity.ONTOLOGY.equals(definition.getOntology().getType()) + || !changeSet.getOntology().getId().equals(definition.getOntology().getId())) { + throw new BadRequestException("A definition payload must belong to the draft's ontology"); + } + if (operation.getTargetId() != null && !operation.getTargetId().equals(definition.getId())) { + throw new BadRequestException("targetId must match the definition id"); + } + } + if ((operation.getTargetId() == null) != (operation.getBaseVersion() == null)) { + throw new BadRequestException( + "Existing definitions require targetId and baseVersion; new definitions require neither"); + } + } + + static void validateTarget( + final OntologyChangeSet changeSet, + final OntologyChangeOperation operation, + final OntologyChangePreflight.EntityLoader loader) { + if (operation.getTargetId() == null) { + return; + } + final OntologyDefinition current = + (OntologyDefinition) loader.load(entityType(operation), operation.getTargetId()); + if (!changeSet.getOntology().getId().equals(current.getOntology().getId()) + || !Objects.equals(operation.getBaseVersion(), current.getVersion()) + || Boolean.TRUE.equals(current.getDeleted())) { + throw new BadRequestException( + "Definition is deleted, outside the draft's ontology, or has changed since drafting"); + } + final OntologyDefinition proposed = definition(operation); + if (proposed != null && !current.getName().equals(proposed.getName())) { + throw new BadRequestException("A draft cannot rename a definition"); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeApplicationService.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeApplicationService.java index e22527852d18..55cf2ce00ecf 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeApplicationService.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeApplicationService.java @@ -41,6 +41,9 @@ import org.openmetadata.service.jdbi3.GlossaryTermRepository; import org.openmetadata.service.jdbi3.OntologyAxiomRepository; import org.openmetadata.service.jdbi3.OntologyChangeSetRepository; +import org.openmetadata.service.jdbi3.OntologyConceptRepository; +import org.openmetadata.service.jdbi3.OntologyPropertyRepository; +import org.openmetadata.service.jdbi3.OntologyRepository; import org.openmetadata.service.ontology.OntologyChangeOperationExecutor.OperationOutcome; import org.openmetadata.service.util.RestUtil.PutResponse; @@ -67,10 +70,8 @@ public final class OntologyChangeApplicationService { public OntologyChangeApplicationService( final OntologyChangeSetRepository changeSetRepository, final GlossaryTermRepository termRepository, - final OntologyAxiomRepository axiomRepository, final Clock clock) { - this( - changeSetRepository, productionDependencies(termRepository, axiomRepository, clock), clock); + this(changeSetRepository, productionDependencies(termRepository, clock), clock); } OntologyChangeApplicationService( @@ -93,34 +94,38 @@ public PutResponse apply( preflight.validate(changeSet, operations); final ApplicationExecution execution = new ApplicationExecution(uriInfo, changeSetId, user, new ArrayList<>()); - final PutResponse response = applyOperations(execution, operations); + final PutResponse response = + applyOperations(execution, changeSet, operations); publishApplied(response.getEntity(), user); return response; } private PutResponse applyOperations( - final ApplicationExecution execution, final List operations) { - PutResponse response; - try { - response = transaction.execute(() -> applyAtomically(execution, operations)); - } catch (OperationApplicationException failure) { - response = recordFailureAndInvalidateCaches(execution, operations, failure); - } - if (response.getEntity().getState() == OntologyChangeSetState.APPLIED) { - invalidateMutationCaches(execution); - } - return response; - } - - private PutResponse recordFailureAndInvalidateCaches( final ApplicationExecution execution, - final List operations, - final OperationApplicationException failure) { + final OntologyChangeSet changeSet, + final List operations) { PutResponse response; try { + response = + transaction.execute( + () -> { + if (changeSet.getOntology() == null) { + return applyAtomically(execution, operations); + } + return executor.inScope( + changeSet, + () -> { + preflight.validate(changeSet, operations); + return applyAtomically(execution, operations); + }); + }); + } catch (OperationApplicationException failure) { response = recordFailure(execution, operations, failure); } finally { invalidateMutationCaches(execution); + if (changeSet.getOntology() != null) { + cacheInvalidator.invalidate(changeSet.getEntityReference()); + } } return response; } @@ -300,16 +305,19 @@ private static OntologyChangeOperationResult result( } private static Dependencies productionDependencies( - final GlossaryTermRepository termRepository, - final OntologyAxiomRepository axiomRepository, - final Clock clock) { + final GlossaryTermRepository termRepository, final Clock clock) { return new Dependencies( productionPreflight(), new OntologyChangeOperationExecutor( (GlossaryRepository) Entity.getEntityRepository(Entity.GLOSSARY), termRepository, - axiomRepository, - clock), + clock, + new NativeOntologyChangeExecutor( + (OntologyRepository) Entity.getEntityRepository(Entity.ONTOLOGY), + (OntologyConceptRepository) Entity.getEntityRepository(Entity.ONTOLOGY_CONCEPT), + (OntologyPropertyRepository) Entity.getEntityRepository(Entity.ONTOLOGY_PROPERTY), + (OntologyAxiomRepository) Entity.getEntityRepository(Entity.ONTOLOGY_AXIOM), + clock)), new OntologyChangeEventPublisher(), work -> termRepository.executeInTransaction(work), OntologyChangeApplicationService::invalidateEntityCache); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeOperationExecutor.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeOperationExecutor.java index e46852a86933..defd4f9eee1b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeOperationExecutor.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeOperationExecutor.java @@ -23,11 +23,12 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.function.Supplier; import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.api.data.ConceptMapping; import org.openmetadata.schema.entity.data.Glossary; import org.openmetadata.schema.entity.data.GlossaryTerm; -import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyChangeSet; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.OntologyAttribute; @@ -38,26 +39,43 @@ import org.openmetadata.schema.utils.JsonUtils; import org.openmetadata.service.jdbi3.GlossaryRepository; import org.openmetadata.service.jdbi3.GlossaryTermRepository; -import org.openmetadata.service.jdbi3.OntologyAxiomRepository; public final class OntologyChangeOperationExecutor { private static final String TERM_EDIT_FIELDS = "attributes,conceptMappings"; private final GlossaryTermRepository termRepository; private final GlossaryRepository glossaryRepository; - private final OntologyAxiomRepository axiomRepository; private final Clock clock; + private final NativeOntologyChangeExecutor nativeExecutor; public OntologyChangeOperationExecutor( final GlossaryRepository glossaryRepository, final GlossaryTermRepository termRepository, - final OntologyAxiomRepository axiomRepository, final Clock clock) { + this(glossaryRepository, termRepository, clock, null); + } + + public OntologyChangeOperationExecutor( + final GlossaryRepository glossaryRepository, + final GlossaryTermRepository termRepository, + final Clock clock, + final NativeOntologyChangeExecutor nativeExecutor) { + this.nativeExecutor = nativeExecutor; this.glossaryRepository = glossaryRepository; this.termRepository = termRepository; - this.axiomRepository = axiomRepository; this.clock = clock; } + public R inScope(final OntologyChangeSet changeSet, final Supplier work) { + return changeSet.getOntology() == null ? work.get() : nativeExecutor().inScope(changeSet, work); + } + + private NativeOntologyChangeExecutor nativeExecutor() { + if (nativeExecutor == null) { + throw new BadRequestException("Native definition operations require an ontology scope"); + } + return nativeExecutor; + } + public OperationOutcome execute( final UriInfo uriInfo, final String user, final OntologyChangeOperation operation) { final OperationOutcome outcome = @@ -72,8 +90,12 @@ public OperationOutcome execute( case DELETE_ATTRIBUTE -> deleteAttribute(uriInfo, user, operation); case UPSERT_MAPPING -> upsertMapping(uriInfo, user, operation); case DELETE_MAPPING -> deleteMapping(uriInfo, user, operation); - case UPSERT_AXIOM -> upsertAxiom(uriInfo, user, operation); - case DELETE_AXIOM -> deleteAxiom(user, operation); + case UPSERT_CONCEPT, + DELETE_CONCEPT, + UPSERT_PROPERTY, + DELETE_PROPERTY, + UPSERT_AXIOM, + DELETE_AXIOM -> nativeExecutor().execute(uriInfo, user, operation); }; return outcome; } @@ -277,26 +299,6 @@ private void prepareTerm(final GlossaryTerm term, final String user) { term.setUpdatedAt(clock.millis()); } - private OperationOutcome upsertAxiom( - final UriInfo uriInfo, final String user, final OntologyChangeOperation operation) { - final OntologyAxiom axiom = JsonUtils.deepCopy(operation.getAxiom(), OntologyAxiom.class); - axiom.setUpdatedBy(user); - axiom.setUpdatedAt(clock.millis()); - axiomRepository.prepareInternal(axiom, operation.getTargetId() != null); - return outcome(axiomRepository.createOrUpdate(uriInfo, axiom, user).getEntity()); - } - - private OperationOutcome deleteAxiom(final String user, final OntologyChangeOperation operation) { - final OntologyAxiom axiom = - axiomRepository.get( - null, operation.getTargetId(), axiomRepository.getFields(""), Include.ALL, false); - final OntologyAxiom deleted = - Boolean.TRUE.equals(axiom.getDeleted()) - ? axiom - : axiomRepository.delete(user, operation.getTargetId(), false, false).entity(); - return outcome(deleted); - } - private static OperationOutcome outcome(final EntityInterface entity) { return new OperationOutcome(entity.getEntityReference(), entity.getVersion()); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangePreflight.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangePreflight.java index fb64b861abfd..a9ea20f4b2ef 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangePreflight.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangePreflight.java @@ -15,9 +15,9 @@ import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; +import com.github.benmanes.caffeine.cache.Caffeine; import jakarta.ws.rs.BadRequestException; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -28,7 +28,6 @@ import org.openmetadata.schema.entity.context.ContextMemory; import org.openmetadata.schema.entity.context.ContextMemoryStatus; import org.openmetadata.schema.entity.data.GlossaryTerm; -import org.openmetadata.schema.entity.data.OntologyAxiom; import org.openmetadata.schema.entity.data.OntologyChangeSet; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.OntologyChangeOperation; @@ -46,10 +45,18 @@ public OntologyChangePreflight(final EntityLoader entityLoader) { public void validate( final OntologyChangeSet changeSet, final List operations) { + final Map activeSourceMemories = + Caffeine.newBuilder().maximumSize(1000).build().asMap(); + if (changeSet.getOntology() != null) { + for (final OntologyChangeOperation operation : operations) { + validateSourceMemories(operation, activeSourceMemories); + NativeOntologyChanges.validateTarget(changeSet, operation, entityLoader); + } + return; + } final Set scope = glossaryScope(changeSet); final List plannedTerms = plannedTerms(operations); final List versionGuards = new ArrayList<>(); - final Map activeSourceMemories = new HashMap<>(); for (final OntologyChangeOperation operation : operations) { validateSourceMemories(operation, activeSourceMemories); validateScope(operation, scope, plannedTerms); @@ -117,8 +124,13 @@ private void validateScope( operation, operation.getTargetId(), scope, plannedTerms); case ADD_RELATIONSHIP, UPDATE_RELATIONSHIP, DELETE_RELATIONSHIP -> validateRelationshipScope( operation, scope, plannedTerms); - case UPSERT_AXIOM -> validateAxiomScope(operation, scope); - case DELETE_AXIOM -> requireAxiomInScope(operation, scope); + case UPSERT_CONCEPT, + DELETE_CONCEPT, + UPSERT_PROPERTY, + DELETE_PROPERTY, + UPSERT_AXIOM, + DELETE_AXIOM -> throw new BadRequestException( + "Axioms must be authored in an ontology, not a glossary change set"); } } @@ -155,20 +167,6 @@ private void requireTermInScope( requireInScope(operation, term.getGlossary().getId(), scope); } - private void validateAxiomScope(final OntologyChangeOperation operation, final Set scope) { - if (operation.getTargetId() == null) { - requireInScope(operation, operation.getAxiom().getGlossary().getId(), scope); - } else { - requireAxiomInScope(operation, scope); - } - } - - private void requireAxiomInScope(final OntologyChangeOperation operation, final Set scope) { - final OntologyAxiom axiom = - (OntologyAxiom) entityLoader.load(Entity.ONTOLOGY_AXIOM, operation.getTargetId()); - requireInScope(operation, axiom.getGlossary().getId(), scope); - } - private static void requireInScope( final OntologyChangeOperation operation, final UUID glossaryId, final Set scope) { if (!scope.contains(glossaryId)) { @@ -240,10 +238,7 @@ private static OperationTarget target(final OntologyChangeOperation operation) { final OperationTarget target = switch (operation.getOperationType()) { case CREATE_GLOSSARY, CREATE_TERM -> null; - case UPSERT_AXIOM -> operation.getTargetId() == null - ? null - : new OperationTarget(Entity.ONTOLOGY_AXIOM, operation.getTargetId()); - case DELETE_AXIOM -> new OperationTarget(Entity.ONTOLOGY_AXIOM, operation.getTargetId()); + case UPSERT_AXIOM, DELETE_AXIOM -> null; default -> new OperationTarget(Entity.GLOSSARY_TERM, operation.getTargetId()); }; return target; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeSetValidator.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeSetValidator.java index a0b00d843275..e09b1b5e89a8 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeSetValidator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeSetValidator.java @@ -37,7 +37,11 @@ public static void normalizeAndValidate(final OntologyChangeSet changeSet) { throw new BadRequestException( "Ontology change set undoCursor must be between 0 and " + operations.size()); } - validateOperations(operations, cursor, plannedTermIds(operations)); + if (changeSet.getOntology() != null) { + NativeOntologyChanges.validate(changeSet, cursor); + } else { + validateOperations(operations, cursor, plannedTermIds(operations)); + } changeSet.setOperations(operations); changeSet.setUndoCursor(cursor); } @@ -150,13 +154,15 @@ private static String payloadRequirement( return requirement; } - private static int payloadCount(final OntologyChangeOperation operation) { + static int payloadCount(final OntologyChangeOperation operation) { int count = operation.getTerm() == null ? 0 : 1; count += operation.getGlossary() == null ? 0 : 1; count += operation.getRelationship() == null ? 0 : 1; count += operation.getAttribute() == null ? 0 : 1; count += operation.getMapping() == null ? 0 : 1; count += operation.getAxiom() == null ? 0 : 1; + count += operation.getConcept() == null ? 0 : 1; + count += operation.getProperty() == null ? 0 : 1; return count; } @@ -170,7 +176,6 @@ private static boolean hasPayload( case RELATIONSHIP -> operation.getRelationship() != null; case ATTRIBUTE -> operation.getAttribute() != null; case MAPPING -> operation.getMapping() != null; - case AXIOM -> operation.getAxiom() != null; }; return isPresent; } @@ -179,9 +184,7 @@ private static void validateOptimisticGuard( final OntologyChangeOperation operation, final Set plannedTermIds) { final boolean isCreate = operation.getOperationType() == OntologyChangeOperationType.CREATE_TERM - || operation.getOperationType() == OntologyChangeOperationType.CREATE_GLOSSARY - || (operation.getOperationType() == OntologyChangeOperationType.UPSERT_AXIOM - && operation.getTargetId() == null); + || operation.getOperationType() == OntologyChangeOperationType.CREATE_GLOSSARY; final boolean targetsPlannedTerm = operation.getTargetId() != null && plannedTermIds.contains(operation.getTargetId()); if (isCreate && (operation.getTargetId() != null || operation.getBaseVersion() != null)) { @@ -251,7 +254,7 @@ private static void validateTargetConsistency(final OntologyChangeOperation oper case UPDATE_TERM -> requireTarget(operation, operation.getTerm().getId(), "updated term"); case ADD_RELATIONSHIP, UPDATE_RELATIONSHIP, DELETE_RELATIONSHIP -> requireTarget( operation, operation.getRelationship().getFromTerm().getId(), "relationship source"); - case UPSERT_AXIOM -> validateAxiomTarget(operation); + case UPSERT_AXIOM -> throw invalid(operation, "axioms require an ontology"); case DELETE_TERM, UPSERT_ATTRIBUTE, DELETE_ATTRIBUTE, @@ -272,13 +275,6 @@ private static void validateCreatedGlossary(final OntologyChangeOperation operat } } - private static void validateAxiomTarget(final OntologyChangeOperation operation) { - requireEntityId(operation, operation.getAxiom().getId(), "axiom"); - if (operation.getTargetId() != null) { - requireTarget(operation, operation.getAxiom().getId(), "updated axiom"); - } - } - private static void requireTarget( final OntologyChangeOperation operation, final UUID entityId, final String targetName) { requireEntityId(operation, entityId, targetName); @@ -299,12 +295,18 @@ private static PayloadKind payloadKind(final OntologyChangeOperationType operati switch (operationType) { case CREATE_GLOSSARY -> PayloadKind.GLOSSARY; case CREATE_TERM, UPDATE_TERM -> PayloadKind.TERM; - case DELETE_TERM, DELETE_AXIOM -> PayloadKind.NONE; + case DELETE_TERM -> PayloadKind.NONE; case ADD_RELATIONSHIP, UPDATE_RELATIONSHIP, DELETE_RELATIONSHIP -> PayloadKind .RELATIONSHIP; case UPSERT_ATTRIBUTE, DELETE_ATTRIBUTE -> PayloadKind.ATTRIBUTE; case UPSERT_MAPPING, DELETE_MAPPING -> PayloadKind.MAPPING; - case UPSERT_AXIOM -> PayloadKind.AXIOM; + case UPSERT_CONCEPT, + DELETE_CONCEPT, + UPSERT_PROPERTY, + DELETE_PROPERTY, + UPSERT_AXIOM, + DELETE_AXIOM -> throw new BadRequestException( + "Axioms must be authored in an ontology, not a glossary change set"); }; return payloadKind; } @@ -321,7 +323,6 @@ private enum PayloadKind { TERM, RELATIONSHIP, ATTRIBUTE, - MAPPING, - AXIOM + MAPPING } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyClassHierarchy.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyClassHierarchy.java new file mode 100644 index 000000000000..e21f107b98a3 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyClassHierarchy.java @@ -0,0 +1,334 @@ +package org.openmetadata.service.ontology; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; + +import java.net.URI; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.apache.jena.vocabulary.OWL2; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.api.data.ConceptMapping; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyProfileViolation; +import org.openmetadata.schema.type.OntologyProfileViolationCode; +import org.openmetadata.schema.type.OntologyProfileViolationSeverity; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.service.rdf.OwlEntityDeclarations; + +/** Class roles and named subclass edges over one bounded working model or pinned release closure. */ +public final class OntologyClassHierarchy { + private static final int MAX_EXPRESSION_STEPS = 10_000; + private static final Set BUILTIN_CLASSES = + Set.of(OWL2.Thing.getURI(), OWL2.Nothing.getURI()); + private final Map> concepts; + private final Map properties; + private final Set cyclicSubjects; + + public OntologyClassHierarchy(final List definitions) { + concepts = + definitions.stream() + .filter(OntologyConcept.class::isInstance) + .map(OntologyConcept.class::cast) + .collect(Collectors.groupingBy(concept -> concept.getIri().toString())); + properties = + definitions.stream() + .filter(OntologyProperty.class::isInstance) + .map(OntologyProperty.class::cast) + .collect( + Collectors.toMap( + property -> property.getIri().toString(), + property -> property, + (first, second) -> first)); + cyclicSubjects = cycleSources(namedEdges(definitions)); + } + + public void validate( + final OntologyDefinition definition, final Consumer findings) { + switch (definition) { + case OntologyConcept concept -> validateConcept(concept, findings); + case OntologyProperty property -> validateProperty(property, findings); + case OntologyAxiom axiom -> validateAxiom(axiom, findings); + default -> {} + } + } + + private void validateConcept( + final OntologyConcept concept, final Consumer findings) { + if (OwlEntityDeclarations.isBuiltIn(concept.getIri().toString())) { + add( + findings, + OntologyProfileViolationCode.RESERVED_SUBJECT_IRI, + "/iri", + "Concept declarations cannot redefine reserved RDF, RDFS, OWL, or XSD vocabulary"); + } + for (final var mapping : listOrEmpty(concept.getConceptMappings())) { + if (mapping.getMappingType() == ConceptMapping.ConceptMappingType.SAME_AS + && (isClass(concept.getIri()) || isClass(mapping.getConceptIri()))) { + add( + findings, + OntologyProfileViolationCode.ILLEGAL_PUNNING, + "/conceptMappings", + "Class concepts cannot use individual sameAs mappings; use an equivalence axiom"); + } + } + } + + private void validateProperty( + final OntologyProperty property, final Consumer findings) { + if (property.getKind() != OntologyPropertyKind.ANNOTATION) { + listOrEmpty(property.getDomain()).forEach(iri -> requireClass(iri, "/domain", findings)); + } + if (property.getKind() == OntologyPropertyKind.OBJECT) { + listOrEmpty(property.getRange()).forEach(iri -> requireClass(iri, "/range", findings)); + } else if (property.getKind() == OntologyPropertyKind.DATA) { + listOrEmpty(property.getRange()).stream() + .filter(iri -> !OwlDatatypeSupport.isSupported(iri)) + .forEach( + iri -> + add( + findings, + OntologyProfileViolationCode.INVALID_EXPRESSION_SHAPE, + "/range", + "Data property ranges require supported OWL 2 datatypes: " + iri)); + if (listOrEmpty(property.getEnumValues()).stream() + .anyMatch(value -> !OwlDatatypeSupport.isValidLiteral(value))) { + add( + findings, + OntologyProfileViolationCode.INVALID_EXPRESSION_SHAPE, + "/enumValues", + "Enum literals require supported OWL 2 datatypes and valid lexical values"); + } + } + } + + private void validateAxiom( + final OntologyAxiom axiom, final Consumer findings) { + validateIndividualIdentity(axiom, findings); + if (isClassAxiom(axiom)) { + requireClass(axiom.getSubjectIri(), "/subjectIri", findings); + } + if (axiom.getAxiomType() == OntologyAxiomType.SUBCLASS_OF + && axiom.getSubjectIri() != null + && cyclicSubjects.contains(axiom.getSubjectIri().toString())) { + add( + findings, + OntologyProfileViolationCode.SUBCLASS_CYCLE, + "/expressions", + "Named subclass edges contain a cycle at " + axiom.getSubjectIri()); + } + validateExpressions(axiom.getExpressions(), findings); + } + + private void validateIndividualIdentity( + final OntologyAxiom axiom, final Consumer findings) { + if (axiom.getAxiomType() == OntologyAxiomType.OBJECT_PROPERTY_ASSERTION + && URI.create(OWL2.sameAs.getURI()).equals(axiom.getPropertyIri()) + && (isClass(axiom.getSubjectIri()) || isClass(axiom.getTargetIri()))) { + add( + findings, + OntologyProfileViolationCode.ILLEGAL_PUNNING, + isClass(axiom.getSubjectIri()) ? "/subjectIri" : "/targetIri", + "Class concepts cannot use individual sameAs assertions; use an equivalence axiom"); + } + } + + private void validateExpressions( + final List expressions, + final Consumer findings) { + final var pending = new ArrayDeque(); + listOrEmpty(expressions).forEach(value -> pending.add(new Expression(value, false))); + int visited = 0; + while (!pending.isEmpty()) { + if (++visited > MAX_EXPRESSION_STEPS || pending.size() > MAX_EXPRESSION_STEPS) { + add( + findings, + OntologyProfileViolationCode.INVALID_EXPRESSION_SHAPE, + "/expressions", + "Class expression traversal exceeds 10000 steps"); + break; + } + validateExpression(pending.removeLast(), pending, findings); + } + } + + private void validateExpression( + final Expression current, + final ArrayDeque pending, + final Consumer findings) { + final OntologyExpression value = current.value(); + if (value == null) { + return; + } + if (value.getKind() == OntologyExpressionKind.NAMED_CLASS && !current.datatype()) { + requireClass(value.getClassIri(), "/expressions/classIri", findings); + } + listOrEmpty(value.getOperands()) + .forEach(operand -> pending.add(new Expression(operand, current.datatype()))); + if (value.getFiller() != null) { + pending.add(new Expression(value.getFiller(), isDataProperty(value.getPropertyIri()))); + } + } + + private boolean isDataProperty(final URI iri) { + final OntologyProperty property = iri == null ? null : properties.get(iri.toString()); + return property != null + ? property.getKind() == OntologyPropertyKind.DATA + : iri != null + && OwlEntityDeclarations.vocabularyKind(iri.toString()) + == OwlEntityDeclarations.EntityKind.DATA_PROPERTY; + } + + private void requireClass( + final URI iri, final String path, final Consumer findings) { + if (iri != null && !isClass(iri)) { + final boolean declared = concepts.containsKey(iri.toString()); + add( + findings, + declared + ? OntologyProfileViolationCode.INVALID_CLASS_KIND + : OntologyProfileViolationCode.UNDECLARED_CLASS, + path, + declared + ? "Class references require OWL_CLASS or BOTH: " + iri + : "Declare or import the referenced class: " + iri); + } + } + + private boolean isClass(final URI iri) { + return iri != null + && (BUILTIN_CLASSES.contains(iri.toString()) + || OwlEntityDeclarations.vocabularyKind(iri.toString()) + == OwlEntityDeclarations.EntityKind.CLASS + || concepts.getOrDefault(iri.toString(), List.of()).stream() + .anyMatch( + concept -> + concept.getConceptType() == OntologyConceptType.OWL_CLASS + || concept.getConceptType() == OntologyConceptType.BOTH)); + } + + private static boolean isClassAxiom(final OntologyAxiom axiom) { + return axiom.getAxiomType() == OntologyAxiomType.SUBCLASS_OF + || axiom.getAxiomType() == OntologyAxiomType.EQUIVALENT_CLASS + || axiom.getAxiomType() == OntologyAxiomType.DISJOINT_WITH; + } + + public static List namedParents(final OntologyAxiom axiom) { + return axiom.getAxiomType() != OntologyAxiomType.SUBCLASS_OF + ? List.of() + : listOrEmpty(axiom.getExpressions()).stream() + .filter( + expression -> + expression != null + && expression.getKind() == OntologyExpressionKind.NAMED_CLASS + && expression.getClassIri() != null) + .map(OntologyExpression::getClassIri) + .toList(); + } + + private static Map> namedEdges( + final List definitions) { + final Map> edges = new HashMap<>(); + definitions.stream() + .filter(OntologyAxiom.class::isInstance) + .map(OntologyAxiom.class::cast) + .filter(axiom -> axiom.getSubjectIri() != null) + .forEach( + axiom -> + namedParents(axiom) + .forEach( + parent -> + edges + .computeIfAbsent( + axiom.getSubjectIri().toString(), ignored -> new HashSet<>()) + .add(parent.toString()))); + return edges; + } + + static Set cycleSources(final Map> edges) { + final Set visited = new HashSet<>(); + final var finished = new ArrayDeque(); + edges.keySet().forEach(root -> finishTraversal(root, edges, visited, finished)); + final Map> reverse = new HashMap<>(); + edges.forEach( + (source, targets) -> + targets.forEach( + target -> reverse.computeIfAbsent(target, ignored -> new HashSet<>()).add(source))); + visited.clear(); + final Set cyclic = new HashSet<>(); + while (!finished.isEmpty()) { + final String root = finished.removeLast(); + final Set component = component(root, reverse, visited); + if (component.size() > 1 || edges.getOrDefault(root, Set.of()).contains(root)) { + cyclic.addAll(component); + } + } + return Set.copyOf(cyclic); + } + + private static void finishTraversal( + final String root, + final Map> edges, + final Set visited, + final ArrayDeque finished) { + final var pending = new ArrayDeque(); + if (visited.add(root)) { + pending.add(new Traversal(root, edges.getOrDefault(root, Set.of()).iterator())); + } + while (!pending.isEmpty()) { + final Traversal current = pending.getLast(); + if (!current.targets().hasNext()) { + finished.add(pending.removeLast().source()); + } else { + final String target = current.targets().next(); + if (visited.add(target)) { + pending.add(new Traversal(target, edges.getOrDefault(target, Set.of()).iterator())); + } + } + } + } + + private static Set component( + final String root, final Map> edges, final Set visited) { + final Set component = new HashSet<>(); + final var pending = new ArrayDeque(); + pending.add(root); + while (!pending.isEmpty()) { + final String current = pending.removeLast(); + if (visited.add(current)) { + component.add(current); + pending.addAll(edges.getOrDefault(current, Set.of())); + } + } + return component; + } + + private static void add( + final Consumer findings, + final OntologyProfileViolationCode code, + final String path, + final String message) { + findings.accept( + new OntologyProfileViolation() + .withCode(code) + .withPath(path) + .withSeverity(OntologyProfileViolationSeverity.ERROR) + .withMessage(message)); + } + + private record Expression(OntologyExpression value, boolean datatype) {} + + private record Traversal(String source, Iterator targets) {} +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyExpressionShape.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyExpressionShape.java new file mode 100644 index 000000000000..e7e05cea4662 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyExpressionShape.java @@ -0,0 +1,49 @@ +package org.openmetadata.service.ontology; + +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; + +import java.util.function.Consumer; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyRestrictionKind; + +/** Rejects fields the RDF projection would otherwise silently discard. */ +final class OntologyExpressionShape { + private OntologyExpressionShape() {} + + static void validate( + final OntologyExpression value, final String path, final Consumer invalid) { + final var kind = value.getKind(); + final boolean restriction = kind == OntologyExpressionKind.RESTRICTION; + final boolean operands = + kind == OntologyExpressionKind.UNION || kind == OntologyExpressionKind.INTERSECTION; + if ((value.getClassIri() != null && kind != OntologyExpressionKind.NAMED_CLASS) + || (!nullOrEmpty(value.getOperands()) && !operands) + || (!nullOrEmpty(value.getIndividualIris()) && kind != OntologyExpressionKind.ONE_OF) + || (!restriction && hasRestrictionFields(value)) + || (restriction && hasUnusedRestrictionFields(value))) { + invalid.accept("Fields do not match the expression kind or restriction operator at " + path); + } + } + + private static boolean hasRestrictionFields(final OntologyExpression value) { + return value.getPropertyIri() != null + || value.getRestrictionKind() != null + || value.getFiller() != null + || value.getCardinality() != null + || value.getIndividualIri() != null + || value.getLiteral() != null; + } + + private static boolean hasUnusedRestrictionFields(final OntologyExpression value) { + final var kind = value.getRestrictionKind(); + final boolean cardinality = + kind == OntologyRestrictionKind.MIN + || kind == OntologyRestrictionKind.MAX + || kind == OntologyRestrictionKind.EXACT; + return (value.getCardinality() != null && !cardinality) + || (value.getFiller() != null && kind == OntologyRestrictionKind.VALUE) + || ((value.getIndividualIri() != null || value.getLiteral() != null) + && kind != OntologyRestrictionKind.VALUE); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyIriMinter.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyIriMinter.java index 449acfbbde44..c0204bc13606 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyIriMinter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyIriMinter.java @@ -44,6 +44,11 @@ public final class OntologyIriMinter { private static final Set SUPPORTED_PLACEHOLDERS = Set.of(GLOSSARY_PLACEHOLDER, TERM_PLACEHOLDER, UUID_PLACEHOLDER); + public static URI mint(final URI namespace, final UUID entityId) { + requireAbsoluteBaseIri(namespace); + return requireAbsoluteIri(join(namespace.toString(), entityId.toString())); + } + public OntologyIriPreview preview( final Glossary glossary, final OntologyIriPreviewRequest request) { final OntologyConfiguration configuration = requireConfiguration(glossary); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyJson.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyJson.java new file mode 100644 index 000000000000..d84d5b6f849c --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyJson.java @@ -0,0 +1,49 @@ +package org.openmetadata.service.ontology; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.DecimalNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Map; +import org.openmetadata.schema.exception.JsonParsingException; +import org.openmetadata.schema.utils.JsonUtils; + +/** Deterministic JSON content across MySQL JSON and PostgreSQL JSONB storage. */ +public final class OntologyJson { + private static final ObjectReader READER = + JsonUtils.getObjectMapper() + .readerFor(JsonNode.class) + .with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); + + private OntologyJson() {} + + public static JsonNode read(final String json) { + try { + return READER.readValue(json); + } catch (JsonProcessingException exception) { + throw new JsonParsingException("Unable to read ontology content", exception); + } + } + + public static JsonNode canonical(final JsonNode value) { + if (value instanceof ObjectNode object) { + final ObjectNode sorted = JsonUtils.getObjectNode(); + object.properties().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> sorted.set(entry.getKey(), canonical(entry.getValue()))); + return sorted; + } + if (value instanceof ArrayNode array) { + final ArrayNode ordered = JsonUtils.getObjectMapper().createArrayNode(); + array.forEach(item -> ordered.add(canonical(item))); + return ordered; + } + if (value.isNumber()) { + return DecimalNode.valueOf(value.decimalValue().stripTrailingZeros()); + } + return value; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyLayerValidator.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyLayerValidator.java index 37d0833803cd..a18114325c0a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyLayerValidator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyLayerValidator.java @@ -184,13 +184,13 @@ private static int layerRank(final OntologyLayer layer) { return rank; } - private static void validatePrefixes(final List prefixes) { + public static void validatePrefixes(final List prefixes) { final Set names = new HashSet<>(); - final Set namespaces = new HashSet<>(); - for (final OntologyPrefix prefix : prefixes) { + final Set namespaces = new HashSet<>(); + for (final OntologyPrefix prefix : listOrEmpty(prefixes)) { requireValidPrefix(prefix); final String normalizedName = prefix.getPrefix().toLowerCase(Locale.ROOT); - if (!names.add(normalizedName) || !namespaces.add(prefix.getNamespace().normalize())) { + if (!names.add(normalizedName) || !namespaces.add(prefix.getNamespace().toString())) { throw new BadRequestException("Ontology prefixes and namespaces must be unique"); } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyPropertyHierarchy.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyPropertyHierarchy.java new file mode 100644 index 000000000000..cadc997813b5 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyPropertyHierarchy.java @@ -0,0 +1,96 @@ +package org.openmetadata.service.ontology; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; + +import java.net.URI; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.jena.vocabulary.OWL2; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.OntologyPropertyKind; + +/** OWL 2 DL simplicity and chain regularity for the supported named-property vocabulary. */ +final class OntologyPropertyHierarchy { + private static final Set COMPOSITE_BUILTINS = + Set.of(OWL2.topObjectProperty.getURI(), OWL2.bottomObjectProperty.getURI()); + private final Map inverseGroups = new HashMap<>(); + private final Set nonSimple; + private final Set irregular; + + OntologyPropertyHierarchy(final List properties) { + final Set objects = + properties.stream() + .filter(property -> property.getKind() == OntologyPropertyKind.OBJECT) + .map(property -> property.getIri().toString()) + .collect(Collectors.toCollection(HashSet::new)); + objects.addAll(COMPOSITE_BUILTINS); + properties.stream() + .filter(property -> objects.contains(property.getIri().toString())) + .filter( + property -> + property.getInverse() != null && objects.contains(property.getInverse().toString())) + .forEach(property -> join(property.getIri().toString(), property.getInverse().toString())); + nonSimple = + properties.stream() + .filter(OwlProfileGuard::isNonSimple) + .map(property -> group(property.getIri().toString())) + .collect(Collectors.toCollection(HashSet::new)); + COMPOSITE_BUILTINS.forEach(iri -> nonSimple.add(group(iri))); + final Map> order = new HashMap<>(); + properties.forEach(property -> addChainOrder(order, property)); + irregular = OntologyClassHierarchy.cycleSources(order); + } + + boolean isNonSimple(final URI iri) { + return nonSimple.contains(group(iri.toString())); + } + + boolean isIrregular(final URI iri) { + return irregular.contains(group(iri.toString())); + } + + private void addChainOrder( + final Map> order, final OntologyProperty property) { + final String superProperty = property.getIri().toString(); + final List chain = + listOrEmpty(property.getPropertyChain()).stream().map(URI::toString).toList(); + if (OWL2.topObjectProperty.getURI().equals(superProperty) + || chain.equals(List.of(superProperty, superProperty))) { + return; + } + final long recursive = chain.stream().filter(superProperty::equals).count(); + for (int index = 0; index < chain.size(); index++) { + final String element = chain.get(index); + if (!(recursive == 1 + && superProperty.equals(element) + && (index == 0 || index == chain.size() - 1))) { + order.computeIfAbsent(group(superProperty), ignored -> new HashSet<>()).add(group(element)); + } + } + } + + private void join(final String first, final String second) { + final String left = group(first); + final String right = group(second); + if (!left.equals(right)) { + inverseGroups.put( + left.compareTo(right) < 0 ? right : left, left.compareTo(right) < 0 ? left : right); + } + } + + private String group(final String iri) { + String root = inverseGroups.computeIfAbsent(iri, value -> value); + while (!root.equals(inverseGroups.get(root))) { + root = inverseGroups.get(root); + } + String current = iri; + while (!current.equals(root)) { + current = inverseGroups.put(current, root); + } + return root; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReferences.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReferences.java new file mode 100644 index 000000000000..7a47684316bc --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReferences.java @@ -0,0 +1,148 @@ +package org.openmetadata.service.ontology; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; + +import java.net.URI; +import java.util.ArrayDeque; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyLiteral; +import org.openmetadata.schema.type.OntologyReference; +import org.openmetadata.schema.type.OntologyReferenceRole; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; + +/** Rebuildable semantic-reference index input; the entity JSON remains authoritative. */ +public final class OntologyReferences { + private static final int MAX_REFERENCES = 10_000; + private final Set targets = new LinkedHashSet<>(); + private int visited; + + private OntologyReferences() {} + + public static List collect(final OntologyDefinition entity) { + if (Boolean.TRUE.equals(entity.getDeleted()) || entity instanceof Ontology) { + return List.of(); + } + final OntologyReferences references = new OntologyReferences(); + final String sourceType = + switch (entity) { + case OntologyConcept concept -> { + references.concept(concept); + yield Entity.ONTOLOGY_CONCEPT; + } + case OntologyProperty property -> { + references.property(property); + yield Entity.ONTOLOGY_PROPERTY; + } + case OntologyAxiom axiom -> { + references.axiom(axiom); + yield Entity.ONTOLOGY_AXIOM; + } + default -> throw new BadRequestException("Unsupported ontology reference source"); + }; + return references.targets.stream() + .map( + target -> + new OntologyReference() + .withOntologyId(entity.getOntology().getId()) + .withSource(entity.getEntityReference().withType(sourceType)) + .withRole(target.role()) + .withIri(URI.create(target.iri()))) + .toList(); + } + + private void concept(final OntologyConcept concept) { + listOrEmpty(concept.getConceptMappings()) + .forEach( + mapping -> { + add(OntologyReferenceRole.MAPPING_TARGET, mapping.getConceptIri()); + add(OntologyReferenceRole.MAPPING_SCHEME, mapping.getSchemeIri()); + }); + } + + private void property(final OntologyProperty property) { + add(OntologyReferenceRole.DOMAIN, property.getDomain()); + add(OntologyReferenceRole.RANGE, property.getRange()); + add(OntologyReferenceRole.INVERSE, property.getInverse()); + add(OntologyReferenceRole.PROPERTY_CHAIN, property.getPropertyChain()); + add(OntologyReferenceRole.DISJOINT_PROPERTY, property.getDisjointWith()); + listOrEmpty(property.getEnumValues()).forEach(this::literal); + } + + private void axiom(final OntologyAxiom axiom) { + add(OntologyReferenceRole.AXIOM_SUBJECT, axiom.getSubjectIri()); + add(OntologyReferenceRole.SUPER_CLASS, OntologyClassHierarchy.namedParents(axiom)); + add(OntologyReferenceRole.PROPERTY, axiom.getPropertyIri()); + add(OntologyReferenceRole.INDIVIDUAL, axiom.getTargetIri()); + literal(axiom.getLiteral()); + final ArrayDeque pending = new ArrayDeque<>(); + schedule(pending, axiom.getExpressions()); + while (!pending.isEmpty()) { + requireCapacity(); + final OntologyExpression expression = pending.removeLast(); + expression(expression); + schedule(pending, expression.getOperands()); + if (expression.getFiller() != null) { + schedule(pending, List.of(expression.getFiller())); + } + } + } + + private void schedule( + final ArrayDeque pending, final List expressions) { + final List children = listOrEmpty(expressions); + if (children.size() > MAX_REFERENCES - visited - pending.size()) { + throw traversalLimit(); + } + pending.addAll(children); + } + + private void expression(final OntologyExpression expression) { + add(OntologyReferenceRole.CLASS_EXPRESSION, expression.getClassIri()); + add(OntologyReferenceRole.PROPERTY, expression.getPropertyIri()); + add(OntologyReferenceRole.INDIVIDUAL, expression.getIndividualIri()); + add(OntologyReferenceRole.INDIVIDUAL, expression.getIndividualIris()); + literal(expression.getLiteral()); + } + + private void literal(final OntologyLiteral literal) { + if (literal != null) { + add(OntologyReferenceRole.DATATYPE, literal.getDatatypeIri()); + } + } + + private void add(final OntologyReferenceRole role, final List iris) { + listOrEmpty(iris).forEach(iri -> add(role, iri)); + } + + private void add(final OntologyReferenceRole role, final URI iri) { + if (iri != null) { + requireCapacity(); + if (!iri.isAbsolute()) { + throw new BadRequestException("Ontology references require absolute IRIs"); + } + targets.add(new Target(role, iri.toString())); + } + } + + private void requireCapacity() { + if (++visited > MAX_REFERENCES) { + throw traversalLimit(); + } + } + + private static BadRequestException traversalLimit() { + return new BadRequestException( + "An ontology definition exceeds the 10000 reference traversal limit"); + } + + private record Target(OntologyReferenceRole role, String iri) {} +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseActivation.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseActivation.java new file mode 100644 index 000000000000..5407046e42b1 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseActivation.java @@ -0,0 +1,113 @@ +package org.openmetadata.service.ontology; + +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.core.Response; +import java.time.Clock; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.api.data.ActivateOntologyRelease; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.OntologyReleaseEvent; +import org.openmetadata.schema.type.OntologyState; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.jdbi3.CollectionDAO; +import org.openmetadata.service.jdbi3.OntologyRepository; +import org.openmetadata.service.rdf.OntologyActiveGraph; +import org.openmetadata.service.util.FreshReadScope; + +/** Commits the active pointer, audit evidence and projection outbox as one module transaction. */ +public final class OntologyReleaseActivation { + private final OntologyRepository ontologies; + private final CollectionDAO dao; + private final Clock clock; + private final Consumer enqueue; + + public record Access(String user, Consumer> authorize) {} + + public OntologyReleaseActivation( + final OntologyRepository ontologies, final Clock clock, final Consumer enqueue) { + this.ontologies = ontologies; + this.dao = ontologies.getDaoCollection(); + this.clock = clock; + this.enqueue = enqueue; + } + + public OntologyState activate( + final UUID ontologyId, final ActivateOntologyRelease request, final Access access) { + final var release = dao.ontologyReleaseDAO().findEntityById(request.getReleaseId()); + requireApprovedOwner(ontologyId, release); + final var members = new OntologyReleaseReader(dao).read(release, access.authorize()); + OntologyStructureValidator.requireValid(release, members); + OntologyActiveGraph.requireProjectable(release, members); + return ontologies.executeInTransaction( + () -> { + try (FreshReadScope.Handle ignored = FreshReadScope.enter()) { + return activateLocked(ontologyId, request, access); + } + }); + } + + private OntologyState activateLocked( + final UUID ontologyId, final ActivateOntologyRelease request, final Access access) { + dao.ontologyStateDAO().initialize(ontologyId); + final var previous = dao.ontologyStateDAO().lock(ontologyId); + final var roots = dao.ontologyEntityDAO(); + roots.jsonToEntity(roots.findJsonByIdForUpdate(ontologyId, Include.NON_DELETED), ontologyId); + requireExpectedRevision(previous, request); + final var releases = dao.ontologyReleaseDAO(); + final var release = + releases.jsonToEntity( + releases.findJsonByIdForUpdate(request.getReleaseId(), Include.NON_DELETED), + request.getReleaseId()); + requireApprovedOwner(ontologyId, release); + return commit(previous, release, access.user()); + } + + private OntologyState commit( + final OntologyState previous, final OntologyRelease release, final String user) { + if (dao.ontologyStateDAO() + .activate(previous.getOntologyId(), release.getId(), previous.getActivationRevision()) + != 1) { + throw new ClientErrorException( + "Ontology activation changed; reload its state", Response.Status.CONFLICT); + } + final var active = dao.ontologyStateDAO().get(previous.getOntologyId()); + dao.ontologyReleaseDAO() + .appendEvent( + new OntologyReleaseEvent() + .withId(UUID.randomUUID()) + .withOntologyId(previous.getOntologyId()) + .withReleaseId(release.getId()) + .withEventType(OntologyReleaseEvent.EventType.ACTIVATED) + .withCreatedAt(clock.millis()) + .withCreatedBy(user) + .withContentDigest(release.getContentDigest()) + .withActivationRevision(active.getActivationRevision()) + .withPreviousReleaseId(previous.getActiveReleaseId())); + // The enqueue fence is acquired last so its order matches the activation commit order. + enqueue.accept(previous.getOntologyId()); + return active; + } + + private static void requireExpectedRevision( + final OntologyState state, final ActivateOntologyRelease request) { + if (request.getExpectedActivationRevision() == null + || request.getExpectedActivationRevision().longValue() != state.getActivationRevision()) { + throw new ClientErrorException( + "Ontology activation changed; reload its state", Response.Status.CONFLICT); + } + } + + private static void requireApprovedOwner(final UUID ontologyId, final OntologyRelease release) { + if (!ontologyId.equals(release.getOntology().getId()) + || release.getSealedAt() == null + || release.getEntityStatus() != EntityStatus.APPROVED) { + throw new BadRequestException( + "Activation requires an approved, sealed release of this ontology"); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseCapture.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseCapture.java new file mode 100644 index 000000000000..4391ab416c0c --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseCapture.java @@ -0,0 +1,200 @@ +package org.openmetadata.service.ontology; + +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.api.data.CaptureOntologyRelease; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.type.OntologyState; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.exception.PreconditionFailedException; +import org.openmetadata.service.jdbi3.CollectionDAO; +import org.openmetadata.service.jdbi3.EntityRepository; +import org.openmetadata.service.jdbi3.ListFilter; +import org.openmetadata.service.jdbi3.OntologyEntityRepository; +import org.openmetadata.service.jdbi3.OntologyReleaseRepository; +import org.openmetadata.service.jdbi3.OntologyRepository; +import org.openmetadata.service.util.FreshReadScope; +import org.openmetadata.service.util.FullyQualifiedName; + +/** Captures bounded working content while holding the same module lock as its writers. */ +public final class OntologyReleaseCapture { + private static final int PAGE_SIZE = 100; + private static final Duration CAPTURE_TIMEOUT = Duration.ofSeconds(30); + private final CollectionDAO dao; + private final OntologyReleaseRepository releases; + private final OntologyRepository ontologies; + private final Map> definitions; + private final Clock clock; + + public record Access(String user, Consumer> authorize) {} + + public OntologyReleaseCapture( + final OntologyReleaseRepository releases, + final OntologyRepository ontologies, + final Map> definitions, + final Clock clock) { + this.releases = releases; + this.dao = releases.getDaoCollection(); + this.ontologies = ontologies; + this.definitions = Map.copyOf(definitions); + this.clock = clock; + } + + public OntologyRelease capture( + final UUID ontologyId, final CaptureOntologyRelease request, final Access access) { + return ontologies.executeInTransaction( + () -> { + try (FreshReadScope.Handle ignored = FreshReadScope.enter()) { + return captureLocked(ontologyId, request, access); + } + }); + } + + private OntologyRelease captureLocked( + final UUID ontologyId, final CaptureOntologyRelease request, final Access access) { + dao.ontologyStateDAO().initialize(ontologyId); + final OntologyState state = dao.ontologyStateDAO().lock(ontologyId); + if (request.getExpectedWorkingRevision() == null + || request.getExpectedWorkingRevision().longValue() != state.getWorkingRevision()) { + throw new PreconditionFailedException( + "Ontology changed; reload its working revision before capture"); + } + final Ontology ontology = + ontologies.get( + null, ontologyId, ontologies.getFields("owners,reviewers"), Include.NON_DELETED, false); + final OntologyRelease release = header(ontology, state, access.user()); + final Members members = new Members(release.getId(), access); + access.authorize().accept(List.of(ontology)); + members.add(MemberKind.ONTOLOGY, ontology); + definitions.forEach((kind, repository) -> members.add(repository, kind, ontologyId)); + OntologyStructureValidator.requireValid(release, members.values); + seal(release, state, members); + return release; + } + + private OntologyRelease header( + final Ontology ontology, final OntologyState state, final String user) { + final long revision = Math.incrementExact(state.getLastReleaseRevision()); + final UUID id = UUID.randomUUID(); + final String name = "release-" + revision; + final OntologyRelease release = + new OntologyRelease() + .withId(id) + .withName(name) + .withFullyQualifiedName(FullyQualifiedName.add(ontology.getFullyQualifiedName(), name)) + .withOntology(ontology.getEntityReference()) + .withOwners(ontology.getOwners()) + .withReviewers(ontology.getReviewers()) + .withOntologyIri(ontology.getIri()) + .withVersionIri(URI.create("urn:openmetadata:ontology-release:" + id)) + .withReleaseRevision(revision) + .withSourceWorkingRevision(state.getWorkingRevision()) + .withBaseReleaseId(state.getActiveReleaseId()) + .withEncodingVersion( + OntologyRelease.EncodingVersion.fromValue(OntologyReleaseManifest.ENCODING_VERSION)) + .withVersion(0.1) + .withUpdatedAt(clock.millis()) + .withUpdatedBy(user) + .withSealedBy(user); + OntologyReview.initialize(release); + return release; + } + + private void seal( + final OntologyRelease release, final OntologyState state, final Members members) { + final String digest = OntologyReleaseManifest.digest(members.values); + members.requireTimeRemaining(); + if (dao.ontologyStateDAO() + .allocateReleaseRevision(state.getOntologyId(), state.getLastReleaseRevision()) + != 1) { + throw new PreconditionFailedException("Ontology release allocation changed"); + } + dao.ontologyReleaseDAO().insert(release, release.getFullyQualifiedName()); + for (final OntologyReleaseMember member : members.values) { + if (dao.ontologyReleaseDAO().insertMember(member) != 1) { + throw new PreconditionFailedException("Ontology release was sealed during capture"); + } + members.requireTimeRemaining(); + } + sealHeader(release, digest, members); + } + + private void sealHeader( + final OntologyRelease release, final String digest, final Members members) { + release + .withContentDigest(digest) + .withMemberCount(members.values.size()) + .withTotalBytes(members.bytes) + .withSealedAt(clock.millis()); + if (dao.ontologyReleaseDAO() + .seal(release.getId(), JsonUtils.pojoToJson(release), members.values.size()) + != 1) { + throw new PreconditionFailedException("Ontology release membership changed before sealing"); + } + releases.recordCapture(release); + members.requireTimeRemaining(); + } + + private static final class Members { + private final UUID releaseId; + private final Access access; + private final long deadline = System.nanoTime() + CAPTURE_TIMEOUT.toNanos(); + private final List values = new ArrayList<>(); + private long bytes; + + private Members(final UUID releaseId, final Access access) { + this.releaseId = releaseId; + this.access = access; + } + + private void add( + final EntityRepository repository, final MemberKind kind, final UUID ontologyId) { + final ListFilter filter = + new ListFilter(Include.NON_DELETED).addQueryParam("ontologyId", ontologyId.toString()); + String after = null; + do { + requireTimeRemaining(); + final ResultList page = + repository.listAfter(null, repository.getFields(""), filter, PAGE_SIZE, after); + access.authorize().accept(page.getData()); + page.getData().forEach(entity -> add(kind, entity)); + after = page.getPaging().getAfter(); + } while (after != null); + } + + private void add(final MemberKind kind, final OntologyDefinition entity) { + requireTimeRemaining(); + if (values.size() >= OntologyReleaseManifest.MAX_MEMBERS) { + throw new BadRequestException("An ontology release exceeds the 10000 member limit"); + } + final OntologyReleaseMember member = + OntologyReleaseManifest.member( + releaseId, kind, entity.getId(), OntologyReleaseContent.project(entity)) + .withSourceVersion(entity.getVersion()); + bytes += member.getPayloadBytes(); + if (bytes > OntologyReleaseManifest.MAX_RELEASE_BYTES) { + throw new BadRequestException("Ontology release content exceeds the 64 MiB limit"); + } + values.add(member); + } + + private void requireTimeRemaining() { + if (System.nanoTime() >= deadline) { + throw new BadRequestException("Ontology capture exceeded its 30 second transaction budget"); + } + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseComparison.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseComparison.java new file mode 100644 index 000000000000..121cce43615a --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseComparison.java @@ -0,0 +1,136 @@ +package org.openmetadata.service.ontology; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.api.data.OntologyReleaseDiff; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.OntologyReleaseChangeType; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMemberChange; +import org.openmetadata.schema.type.Paging; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.jdbi3.OntologyReleaseRepository; +import org.openmetadata.service.util.RestUtil; + +/** Compares governed content from authorized, verified releases. */ +public final class OntologyReleaseComparison { + private final OntologyReleaseReader reader; + + public OntologyReleaseComparison(final OntologyReleaseRepository releases) { + reader = new OntologyReleaseReader(releases.getDaoCollection()); + } + + public OntologyReleaseDiff compare( + final OntologyRelease release, + final OntologyRelease base, + final int limit, + final String after, + final Consumer> authorize) { + if (limit < 1 || limit > 1000) { + throw new BadRequestException("A release comparison page requires a limit from 1 to 1000"); + } + if (base != null + && !Objects.equals(release.getOntology().getId(), base.getOntology().getId())) { + throw new BadRequestException("Compared releases must belong to the same ontology"); + } + final var proposed = reader.read(release, authorize); + final var previous = + base == null ? List.of() : reader.read(base, authorize); + final var changes = compareMembers(previous, proposed); + final String prefix = + "release-diff-v1:" + release.getId() + ":" + (base == null ? "empty" : base.getId()) + ":"; + final int start = offset(after, prefix, changes.size()); + final int end = Math.min(start + limit, changes.size()); + return new OntologyReleaseDiff() + .withReleaseId(release.getId()) + .withContentDigest(release.getContentDigest()) + .withBaseReleaseId(base == null ? null : base.getId()) + .withBaseContentDigest(base == null ? null : base.getContentDigest()) + .withData(List.copyOf(changes.subList(start, end))) + .withPaging( + new Paging() + .withTotal(changes.size()) + .withAfter(end < changes.size() ? RestUtil.encodeCursor(prefix + end) : null)); + } + + static List compareMembers( + final List before, final List after) { + final var previous = indexMembers(before); + final var proposed = indexMembers(after); + return keys(previous, proposed) + .map(key -> memberChange(previous.get(key), proposed.get(key))) + .filter(Objects::nonNull) + .toList(); + } + + private static Map indexMembers(final List members) { + OntologyReleaseReader.requireBounds( + members.size(), members.stream().mapToLong(OntologyReleaseMember::getPayloadBytes).sum()); + final Map indexed = new HashMap<>(); + for (final var member : members) { + final String key = member.getMemberKind().value() + ":" + member.getMemberId(); + final var snapshot = new Snapshot(member, OntologyReleaseReader.definition(member)); + if (indexed.putIfAbsent(key, snapshot) != null) { + throw new BadRequestException( + "Ontology release closure contains a duplicate member identity"); + } + } + return indexed; + } + + private static OntologyReleaseMemberChange memberChange( + final Snapshot before, final Snapshot after) { + if (before != null + && after != null + && OntologyReview.digest(before.definition()) + .equals(OntologyReview.digest(after.definition()))) { + return null; + } + final Snapshot selected = after == null ? before : after; + final OntologyDefinition entity = selected.definition(); + return new OntologyReleaseMemberChange() + .withEntity(entity.getEntityReference().withDescription(null).withHref(null)) + .withOntologyId( + entity.getOntology() == null ? entity.getId() : entity.getOntology().getId()) + .withMemberKind(selected.member().getMemberKind()) + .withChangeType(changeType(before, after)) + .withBeforeReleaseId(before == null ? null : before.member().getReleaseId()) + .withAfterReleaseId(after == null ? null : after.member().getReleaseId()); + } + + private static Stream keys(final Map before, final Map after) { + return Stream.concat(before.keySet().stream(), after.keySet().stream()).distinct().sorted(); + } + + private static OntologyReleaseChangeType changeType(final Object before, final Object after) { + return before == null + ? OntologyReleaseChangeType.ADDED + : after == null ? OntologyReleaseChangeType.REMOVED : OntologyReleaseChangeType.CHANGED; + } + + private static int offset(final String after, final String prefix, final int total) { + if (after == null) { + return 0; + } + try { + final String decoded = after.length() > 256 ? null : RestUtil.decodeCursor(after); + if (decoded == null || !decoded.startsWith(prefix)) { + throw new IllegalArgumentException(); + } + final int offset = Integer.parseInt(decoded.substring(prefix.length())); + if (offset < 0 || offset > total) { + throw new IllegalArgumentException(); + } + return offset; + } catch (IllegalArgumentException exception) { + throw new BadRequestException("Invalid cursor for this ontology release comparison"); + } + } + + private record Snapshot(OntologyReleaseMember member, OntologyDefinition definition) {} +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseContent.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseContent.java new file mode 100644 index 000000000000..ff48d74d7aa5 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseContent.java @@ -0,0 +1,58 @@ +package org.openmetadata.service.ontology; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.ArrayList; +import java.util.List; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.utils.JsonUtils; + +/** The explicit ontology-release-v1 content contract, independent of live entity metadata. */ +public final class OntologyReleaseContent { + private OntologyReleaseContent() {} + + public static ObjectNode project(final OntologyDefinition definition) { + final List fields = + new ArrayList<>( + List.of("id", "name", "fullyQualifiedName", "displayName", "description", "ontology")); + fields.addAll(semanticFields(definition)); + final ObjectNode content = JsonUtils.getObjectMapper().valueToTree(definition); + content.retain(fields); + if (content.get("ontology") instanceof ObjectNode reference) { + reference.retain("id", "type"); + } + return content; + } + + private static List semanticFields(final OntologyDefinition definition) { + return switch (definition) { + case Ontology ignored -> List.of("iri", "namespace", "layer", "prefixes"); + case OntologyConcept ignored -> List.of("iri", "conceptType", "synonyms", "conceptMappings"); + case OntologyProperty ignored -> List.of( + "iri", + "kind", + "domain", + "range", + "enumValues", + "unit", + "isIdentifier", + "inverse", + "characteristics", + "propertyChain", + "disjointWith"); + case OntologyAxiom ignored -> List.of( + "axiomType", + "subjectIri", + "expressions", + "propertyIri", + "targetIri", + "literal", + "provenance"); + default -> throw new IllegalArgumentException( + "Unsupported ontology content: " + definition.getClass()); + }; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseManifest.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseManifest.java new file mode 100644 index 000000000000..31274030b220 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseManifest.java @@ -0,0 +1,157 @@ +package org.openmetadata.service.ontology; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.apache.commons.codec.digest.DigestUtils; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.exception.BadRequestException; + +/** Canonical members and a bounded, database-independent release manifest. */ +public final class OntologyReleaseManifest { + public static final String ENCODING_VERSION = "ontology-release-v1"; + public static final int MAX_MEMBER_BYTES = 1_048_576; + public static final int MAX_MEMBERS = 10_000; + public static final long MAX_RELEASE_BYTES = 67_108_864L; + + private OntologyReleaseManifest() {} + + public static void verify( + final OntologyRelease release, final List members) { + if (release == null + || release.getId() == null + || release.getSealedAt() == null + || release.getOntology() == null + || release.getOntology().getId() == null + || release.getOntologyIri() == null + || release.getEncodingVersion() == null + || !ENCODING_VERSION.equals(release.getEncodingVersion().value()) + || release.getVersionIri() == null + || !("urn:openmetadata:ontology-release:" + release.getId()) + .equals(release.getVersionIri().toString())) { + throw new BadRequestException("A supported sealed ontology release is required"); + } + final String digest = digest(members); + final long bytes = members.stream().mapToLong(OntologyReleaseMember::getPayloadBytes).sum(); + if (!Objects.equals(release.getContentDigest(), digest) + || !Objects.equals(release.getMemberCount(), members.size()) + || !Objects.equals(release.getTotalBytes(), bytes) + || members.stream().anyMatch(member -> !release.getId().equals(member.getReleaseId()))) { + throw new BadRequestException("Ontology release manifest integrity check failed"); + } + } + + public static OntologyReleaseMember member( + final UUID releaseId, final MemberKind kind, final UUID id, final JsonNode payload) { + if (releaseId == null || kind == null || id == null || payload == null || !payload.isObject()) { + throw new BadRequestException("A release member requires an identity and an object payload"); + } + final ObjectNode source = ((ObjectNode) payload).deepCopy(); + if (kind == MemberKind.CONCEPT) { + source.remove(List.of("superClasses", "subClasses")); + } + final JsonNode canonical = OntologyJson.canonical(source); + requireSupportedText(canonical); + final String json = canonical.toString(); + final byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + if (bytes.length > MAX_MEMBER_BYTES) { + throw new BadRequestException("An ontology release member exceeds the 1 MiB limit"); + } + return new OntologyReleaseMember() + .withReleaseId(releaseId) + .withMemberKind(kind) + .withMemberId(id) + .withSourceVersion(payload.hasNonNull("version") ? payload.get("version").asDouble() : null) + .withPayload(canonical) + .withPayloadDigest(DigestUtils.sha256Hex(bytes)) + .withPayloadBytes((long) bytes.length); + } + + private static void requireSupportedText(final JsonNode value) { + if (value.isTextual()) { + requireSupportedText(value.textValue()); + } + value.properties().forEach(entry -> requireSupportedText(entry.getKey())); + value.forEach(OntologyReleaseManifest::requireSupportedText); + } + + private static void requireSupportedText(final String value) { + if (value.indexOf('\u0000') >= 0) { + throw new BadRequestException("Ontology release content cannot contain NUL characters"); + } + } + + public static String digest(final List members) { + if (members == null || members.isEmpty() || members.size() > MAX_MEMBERS) { + throw new BadRequestException("An ontology release requires between 1 and 10000 members"); + } + for (final OntologyReleaseMember member : members) { + if (member == null || member.getMemberKind() == null || member.getMemberId() == null) { + throw new BadRequestException("A release member requires an identity"); + } + } + final List ordered = + members.stream() + .sorted( + Comparator.comparing( + (OntologyReleaseMember member) -> member.getMemberKind().name()) + .thenComparing(member -> member.getMemberId().toString())) + .toList(); + return digestOrdered(ordered); + } + + private static String digestOrdered(final List members) { + final MessageDigest digest = DigestUtils.getSha256Digest(); + digest.update((ENCODING_VERSION + '\n').getBytes(StandardCharsets.UTF_8)); + long totalBytes = 0; + OntologyReleaseMember previous = null; + for (final OntologyReleaseMember member : members) { + requireValidMember(member, previous); + totalBytes += member.getPayloadBytes(); + if (totalBytes > MAX_RELEASE_BYTES) { + throw new BadRequestException("Ontology release content exceeds the 64 MiB limit"); + } + final ObjectNode entry = JsonUtils.getObjectMapper().valueToTree(member); + entry.remove(List.of("releaseId", "payload")); + digest.update(OntologyJson.canonical(entry).toString().getBytes(StandardCharsets.UTF_8)); + digest.update((byte) '\n'); + previous = member; + } + return HexFormat.of().formatHex(digest.digest()); + } + + public static void verifyMember(final OntologyReleaseMember member) { + requireValidMember(member, null); + } + + private static void requireValidMember( + final OntologyReleaseMember member, final OntologyReleaseMember previous) { + final OntologyReleaseMember expected = + member( + member.getReleaseId(), + member.getMemberKind(), + member.getMemberId(), + member.getPayload()); + if (!Objects.equals(member.getPayloadDigest(), expected.getPayloadDigest()) + || !Objects.equals(member.getPayloadBytes(), expected.getPayloadBytes()) + || (member.getPayload().hasNonNull("version") + && !Objects.equals(member.getSourceVersion(), expected.getSourceVersion()))) { + throw new BadRequestException("Ontology release member integrity check failed"); + } + if (previous != null + && (!Objects.equals(previous.getReleaseId(), member.getReleaseId()) + || (previous.getMemberKind() == member.getMemberKind() + && previous.getMemberId().equals(member.getMemberId())))) { + throw new BadRequestException("Release members must have unique identities and one release"); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseQuery.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseQuery.java new file mode 100644 index 000000000000..d2a7e43b88b1 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseQuery.java @@ -0,0 +1,51 @@ +package org.openmetadata.service.ontology; + +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; +import org.apache.jena.rdf.model.Model; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.api.rdf.SparqlQuery; +import org.openmetadata.schema.type.Include; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.jdbi3.OntologyReleaseRepository; +import org.openmetadata.service.jdbi3.OntologyRepository; +import org.openmetadata.service.rdf.OntologyReleaseProjection; +import org.openmetadata.service.rdf.OntologySparqlQueryService; +import org.openmetadata.service.rdf.federation.SparqlFederationGuard; + +/** Queries the active release's asserted graph, including while its asynchronous RDF write is pending. */ +public final class OntologyReleaseQuery { + private final OntologyRepository ontologies; + private final OntologyReleaseRepository releases; + + public OntologyReleaseQuery( + final OntologyRepository ontologies, final OntologyReleaseRepository releases) { + this.ontologies = ontologies; + this.releases = releases; + } + + public OntologySparqlQueryService.QueryResult query( + final UUID id, + final SparqlQuery request, + final Consumer> authorize) { + return new OntologySparqlQueryService( + (ontologyId, ignored) -> activeModel(ontologyId, authorize), + new SparqlFederationGuard(null)) + .query(id, request); + } + + private Model activeModel( + final UUID id, final Consumer> authorize) { + final UUID releaseId = ontologies.getState(id).getActiveReleaseId(); + if (releaseId == null) { + throw new BadRequestException("Activate an approved ontology release before querying it"); + } + final var release = + releases.get( + null, releaseId, releases.getFields("owners,reviewers"), Include.NON_DELETED, false); + final var members = + new OntologyReleaseReader(releases.getDaoCollection()).read(release, authorize); + return new OntologyReleaseProjection().build(release, members); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseReader.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseReader.java new file mode 100644 index 000000000000..47b9cd7bb169 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseReader.java @@ -0,0 +1,106 @@ +package org.openmetadata.service.ontology; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.jdbi3.CollectionDAO; + +/** Reads bounded, verified SQL snapshots without consulting mutable definition rows. */ +public final class OntologyReleaseReader { + private static final int PAGE_SIZE = 100; + private final CollectionDAO dao; + + public OntologyReleaseReader(final CollectionDAO dao) { + this.dao = dao; + } + + public List read( + final OntologyRelease release, final Consumer> authorize) { + authorize.accept(List.of(release)); + final List members = new ArrayList<>(); + long bytes = 0; + for (final MemberKind kind : MemberKind.values()) { + String after = ""; + List page; + do { + page = + dao.ontologyReleaseDAO().listMembers(release.getId(), kind.value(), after, PAGE_SIZE); + for (final var member : page) { + bytes += member.getPayloadBytes(); + requireBounds(members.size() + 1, bytes); + members.add(member); + } + if (!page.isEmpty()) { + authorize.accept(page.stream().map(member -> definition(release, member)).toList()); + after = page.getLast().getMemberId().toString(); + } + } while (page.size() == PAGE_SIZE); + } + OntologyReleaseManifest.verify(release, members); + return List.copyOf(members); + } + + public static void requireBounds(final int count, final long bytes) { + if (count > OntologyReleaseManifest.MAX_MEMBERS + || bytes > OntologyReleaseManifest.MAX_RELEASE_BYTES) { + throw new BadRequestException("Ontology release exceeds its member or byte limit"); + } + } + + public static OntologyDefinition definition( + final OntologyRelease release, final OntologyReleaseMember member) { + if (release.getEncodingVersion() == null + || !OntologyReleaseManifest.ENCODING_VERSION.equals(release.getEncodingVersion().value())) { + throw new BadRequestException("Unsupported ontology release encoding"); + } + final OntologyDefinition value = definition(member); + if (value instanceof Ontology ontology) { + if (!Objects.equals(release.getOntology().getId(), ontology.getId()) + || !Objects.equals(release.getOntologyIri().toString(), ontology.getIri().toString())) { + throw new BadRequestException("Ontology release root does not match its header"); + } + } else if (value.getOntology() == null + || !Objects.equals(release.getOntology().getId(), value.getOntology().getId())) { + throw new BadRequestException("Ontology release contains a definition from another ontology"); + } + return value; + } + + public static OntologyDefinition definition(final OntologyReleaseMember member) { + final Class type = + switch (member.getMemberKind()) { + case ONTOLOGY -> Ontology.class; + case CONCEPT -> OntologyConcept.class; + case PROPERTY -> OntologyProperty.class; + case AXIOM -> OntologyAxiom.class; + default -> throw new BadRequestException( + "Unsupported ontology release member: " + member.getMemberKind()); + }; + final var value = JsonUtils.convertValueLenient(member.getPayload(), type); + if (!Objects.equals(member.getMemberId(), value.getId())) { + throw new BadRequestException("Ontology release member identity does not match its payload"); + } + return value; + } + + public static Ontology root( + final OntologyRelease release, final List members) { + final var roots = + members.stream().filter(member -> member.getMemberKind() == MemberKind.ONTOLOGY).toList(); + if (roots.size() != 1) { + throw new BadRequestException("An ontology release requires exactly one ontology root"); + } + return (Ontology) definition(release, roots.getFirst()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseStructure.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseStructure.java new file mode 100644 index 000000000000..7d5799a4d94e --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseStructure.java @@ -0,0 +1,22 @@ +package org.openmetadata.service.ontology; + +import java.util.List; +import java.util.function.Consumer; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.api.data.OntologyReleaseStructureReport; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.service.jdbi3.OntologyReleaseRepository; + +/** Evaluates the authorized immutable closure without changing review or publication state. */ +public final class OntologyReleaseStructure { + private final OntologyReleaseReader reader; + + public OntologyReleaseStructure(final OntologyReleaseRepository releases) { + reader = new OntologyReleaseReader(releases.getDaoCollection()); + } + + public OntologyReleaseStructureReport report( + final OntologyRelease release, final Consumer> authorize) { + return OntologyStructureValidator.validate(release, reader.read(release, authorize)); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReview.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReview.java new file mode 100644 index 000000000000..9c5dc3cefabc --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReview.java @@ -0,0 +1,69 @@ +package org.openmetadata.service.ontology; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Objects; +import java.util.UUID; +import org.apache.commons.codec.digest.DigestUtils; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.OntologyReviewTarget; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.exception.BadRequestException; + +/** Content identity shared by ontology persistence and the existing approval-task lifecycle. */ +public final class OntologyReview { + private OntologyReview() {} + + public static void initialize(final OntologyRelease entity) { + entity.setEntityStatus(EntityStatus.DRAFT); + entity.setReviewAuthor(entity.getUpdatedBy()); + entity.setReviewTask(null); + } + + public static OntologyReviewTarget target(final OntologyRelease entity) { + return new OntologyReviewTarget() + .withEntityId(entity.getId()) + .withOntologyId(ontologyId(entity)) + .withEntityVersion(entity.getVersion()) + .withContentDigest(digest(entity)) + .withAuthor(entity.getReviewAuthor()); + } + + public static void requireIndependentReviewer( + final OntologyRelease entity, final Task task, final String user) { + if (Objects.equals(entity.getReviewAuthor(), user) + || (task.getCreatedBy() != null && Objects.equals(task.getCreatedBy().getName(), user))) { + throw new BadRequestException("Ontology approval requires an independent reviewer"); + } + } + + public static void validateTarget( + final OntologyRelease entity, final OntologyReviewTarget target) { + if (target == null + || target.getEntityVersion() == null + || !Objects.equals(entity.getId(), target.getEntityId()) + || !Objects.equals(ontologyId(entity), target.getOntologyId()) + || !Objects.equals(digest(entity), target.getContentDigest())) { + throw new BadRequestException( + "Ontology content changed; submit its current revision for review"); + } + } + + public static String digest(final OntologyDefinition entity) { + if (entity instanceof OntologyRelease release) { + final ObjectNode identity = JsonUtils.getObjectNode(); + identity.put("id", release.getId().toString()); + identity.put("releaseRevision", release.getReleaseRevision()); + identity.put("contentDigest", release.getContentDigest()); + return DigestUtils.sha256Hex("ontology-review-v3\n" + OntologyJson.canonical(identity)); + } + return DigestUtils.sha256Hex( + OntologyJson.canonical(OntologyReleaseContent.project(entity)).toString()); + } + + private static UUID ontologyId(final OntologyDefinition entity) { + return entity.getOntology() == null ? entity.getId() : entity.getOntology().getId(); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyStructureValidator.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyStructureValidator.java new file mode 100644 index 000000000000..0925b72296dc --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyStructureValidator.java @@ -0,0 +1,308 @@ +package org.openmetadata.service.ontology; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.apache.jena.vocabulary.OWL2; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.api.data.OntologyProfileReport; +import org.openmetadata.schema.api.data.OntologyReleaseStructureReport; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.type.OntologyProfileViolation; +import org.openmetadata.schema.type.OntologyProfileViolationCode; +import org.openmetadata.schema.type.OntologyProfileViolationSeverity; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.OntologyReferenceRole; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.type.OntologyReleaseStructureIssue; +import org.openmetadata.schema.type.RelationshipCharacteristic; +import org.openmetadata.service.exception.OntologyValidationException; +import org.openmetadata.service.rdf.OwlEntityDeclarations; + +/** Structural and OWL 2 DL checks for the supported authoring model; no logical reasoning. */ +public final class OntologyStructureValidator { + private static final int MAX_FINDINGS = 1000; + private static final Set INDIVIDUAL_IDENTITY = + Set.of(OWL2.sameAs.getURI(), OWL2.differentFrom.getURI()); + private static final Set SIMPLE_ONLY = + Set.of( + RelationshipCharacteristic.FUNCTIONAL, + RelationshipCharacteristic.INVERSE_FUNCTIONAL, + RelationshipCharacteristic.ASYMMETRIC, + RelationshipCharacteristic.IRREFLEXIVE); + + private final Map> properties; + private final OntologyPropertyHierarchy propertyHierarchy; + private final OntologyClassHierarchy hierarchy; + private final List findings = new ArrayList<>(); + private int violationCount; + + private OntologyStructureValidator(final List definitions) { + hierarchy = new OntologyClassHierarchy(definitions.stream().map(Definition::value).toList()); + properties = + definitions.stream() + .map(Definition::value) + .filter(OntologyProperty.class::isInstance) + .map(OntologyProperty.class::cast) + .collect(Collectors.groupingBy(property -> property.getIri().toString())); + propertyHierarchy = + new OntologyPropertyHierarchy(properties.values().stream().flatMap(List::stream).toList()); + } + + public static OntologyReleaseStructureReport validate( + final OntologyRelease release, final List members) { + OntologyReleaseReader.requireBounds( + members.size(), members.stream().mapToLong(OntologyReleaseMember::getPayloadBytes).sum()); + final var definitions = + members.stream() + .sorted( + Comparator.comparing( + (OntologyReleaseMember member) -> member.getReleaseId().toString()) + .thenComparing(member -> member.getMemberKind().value()) + .thenComparing(member -> member.getMemberId().toString())) + .map(member -> new Definition(member, OntologyReleaseReader.definition(member))) + .toList(); + final var validator = new OntologyStructureValidator(definitions); + definitions.forEach(validator::validate); + return new OntologyReleaseStructureReport() + .withReleaseId(release.getId()) + .withContentDigest(release.getContentDigest()) + .withValid(validator.violationCount == 0) + .withViolationCount(validator.violationCount) + .withViolations(List.copyOf(validator.findings)); + } + + public static void requireValid( + final OntologyRelease release, final List members) { + final var report = validate(release, members); + if (!report.getValid()) { + throw new OntologyValidationException(report.getViolationCount(), report.getViolations()); + } + } + + public static void requireValidWorking(final List values) { + requireValidWorking( + values, values.stream().map(OntologyDefinition::getId).collect(Collectors.toSet())); + } + + public static void requireValidWorking( + final List values, final Set affected) { + final var validator = validateWorkingDefinitions(values, affected); + if (validator.violationCount != 0) { + throw new OntologyValidationException(validator.violationCount, validator.findings); + } + } + + public static OntologyProfileReport validateWorking(final List values) { + return validateWorking( + values, values.stream().map(OntologyDefinition::getId).collect(Collectors.toSet())); + } + + public static OntologyProfileReport validateWorking( + final List values, final Set affected) { + final var validator = validateWorkingDefinitions(values, affected); + return new OntologyProfileReport() + .withValid(validator.violationCount == 0) + .withViolations( + validator.findings.stream().map(OntologyReleaseStructureIssue::getViolation).toList()); + } + + private static OntologyStructureValidator validateWorkingDefinitions( + final List values, final Set affected) { + final var definitions = + values.stream() + .map( + value -> + new Definition( + new OntologyReleaseMember() + .withMemberId(value.getId()) + .withMemberKind( + switch (value) { + case Ontology ontology -> MemberKind.ONTOLOGY; + case OntologyConcept concept -> MemberKind.CONCEPT; + case OntologyProperty property -> MemberKind.PROPERTY; + case OntologyAxiom axiom -> MemberKind.AXIOM; + default -> throw new IllegalArgumentException( + "Unsupported ontology definition"); + }), + value)) + .toList(); + final var validator = new OntologyStructureValidator(definitions); + definitions.stream() + .filter(definition -> affected.contains(definition.value().getId())) + .forEach(validator::validate); + return validator; + } + + private void validate(final Definition definition) { + hierarchy.validate(definition.value(), violation -> add(definition.member(), violation)); + switch (definition.value()) { + case OntologyProperty property -> validateProperty(definition.member(), property); + case OntologyAxiom axiom -> validateAxiom(definition.member(), axiom); + default -> {} + } + } + + private void validateAxiom(final OntologyReleaseMember member, final OntologyAxiom axiom) { + final var guard = new OwlProfileGuard(this::property, propertyHierarchy::isNonSimple); + final var report = guard.validate(axiom); + report.getViolations().forEach(violation -> add(member, violation)); + if (report.getValid()) { + OntologyReferences.collect(axiom).stream() + .filter(reference -> reference.getRole() == OntologyReferenceRole.PROPERTY) + .filter(reference -> !isIdentityAssertion(axiom)) + .forEach(reference -> requireProperty(member, reference.getIri(), null, "/propertyIri")); + } + } + + private static boolean isIdentityAssertion(final OntologyAxiom axiom) { + return axiom.getAxiomType() == OntologyAxiomType.OBJECT_PROPERTY_ASSERTION + && axiom.getPropertyIri() != null + && INDIVIDUAL_IDENTITY.contains(axiom.getPropertyIri().toString()); + } + + private void validateProperty( + final OntologyReleaseMember member, final OntologyProperty property) { + final OntologyProperty vocabulary = vocabularyProperty(property.getIri()); + if ((vocabulary != null && vocabulary.getKind() != property.getKind()) + || INDIVIDUAL_IDENTITY.contains(property.getIri().toString()) + || OWL2.topDataProperty.getURI().equals(property.getIri().toString()) + || (vocabulary == null && OwlEntityDeclarations.isBuiltIn(property.getIri().toString()))) { + add( + member, + OntologyProfileViolationCode.ILLEGAL_PUNNING, + "/kind", + "The declaration conflicts with the fixed vocabulary meaning: " + property.getIri()); + } + if (properties.get(property.getIri().toString()).stream() + .map(OntologyProperty::getKind) + .distinct() + .count() + > 1) { + add( + member, + OntologyProfileViolationCode.ILLEGAL_PUNNING, + "/kind", + "The closure declares incompatible property kinds for " + property.getIri()); + } + if (property.getInverse() != null) { + requireProperty(member, property.getInverse(), OntologyPropertyKind.OBJECT, "/inverse"); + } + listOrEmpty(property.getPropertyChain()) + .forEach( + iri -> requireProperty(member, iri, OntologyPropertyKind.OBJECT, "/propertyChain")); + listOrEmpty(property.getDisjointWith()) + .forEach(iri -> requireProperty(member, iri, property.getKind(), "/disjointWith")); + validateSimplePropertyUse(member, property); + if (propertyHierarchy.isIrregular(property.getIri())) { + add( + member, + OntologyProfileViolationCode.PROPERTY_CHAIN_REGULARITY, + "/propertyChain", + "Property chains and inverses do not admit a regular property order"); + } + } + + private void validateSimplePropertyUse( + final OntologyReleaseMember member, final OntologyProperty property) { + if (propertyHierarchy.isNonSimple(property.getIri()) + && ((!nullOrEmpty(property.getCharacteristics()) + && property.getCharacteristics().stream().anyMatch(SIMPLE_ONLY::contains)) + || !listOrEmpty(property.getDisjointWith()).isEmpty())) { + add( + member, + OntologyProfileViolationCode.NON_SIMPLE_PROPERTY, + "/characteristics", + "A non-simple object property cannot be functional, inverse-functional, asymmetric, irreflexive or disjoint"); + } + listOrEmpty(property.getDisjointWith()).stream() + .filter(propertyHierarchy::isNonSimple) + .forEach( + iri -> + add( + member, + OntologyProfileViolationCode.NON_SIMPLE_PROPERTY, + "/disjointWith", + "Property disjointness requires a simple object property: " + iri)); + } + + private void requireProperty( + final OntologyReleaseMember member, + final URI iri, + final OntologyPropertyKind expected, + final String path) { + final OntologyProperty target = property(iri); + if (target == null) { + add( + member, + OntologyProfileViolationCode.UNDECLARED_PROPERTY, + path, + "Declare or import the referenced property: " + iri); + } else if (expected != null && target.getKind() != expected) { + add( + member, + OntologyProfileViolationCode.ILLEGAL_PUNNING, + path, + "The referenced property must have kind " + expected + ": " + iri); + } + } + + private OntologyProperty property(final URI iri) { + final var declarations = properties.get(iri.toString()); + return declarations == null ? vocabularyProperty(iri) : declarations.getFirst(); + } + + private static OntologyProperty vocabularyProperty(final URI iri) { + final OntologyPropertyKind kind = + switch (OwlEntityDeclarations.vocabularyKind(iri.toString())) { + case OBJECT_PROPERTY -> OntologyPropertyKind.OBJECT; + case DATA_PROPERTY -> OntologyPropertyKind.DATA; + case ANNOTATION_PROPERTY -> OntologyPropertyKind.ANNOTATION; + case CLASS, DATATYPE, NAMED_INDIVIDUAL -> null; + case null -> null; + }; + return kind == null ? null : new OntologyProperty().withIri(iri).withKind(kind); + } + + private void add( + final OntologyReleaseMember member, + final OntologyProfileViolationCode code, + final String path, + final String message) { + add( + member, + new OntologyProfileViolation() + .withCode(code) + .withPath(path) + .withMessage(message) + .withSeverity(OntologyProfileViolationSeverity.ERROR)); + } + + private void add(final OntologyReleaseMember member, final OntologyProfileViolation violation) { + violationCount++; + if (findings.size() < MAX_FINDINGS) { + findings.add( + new OntologyReleaseStructureIssue() + .withReleaseId(member.getReleaseId()) + .withMemberId(member.getMemberId()) + .withMemberKind(member.getMemberKind()) + .withViolation(violation)); + } + } + + private record Definition(OntologyReleaseMember member, OntologyDefinition value) {} +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OwlDatatypeSupport.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OwlDatatypeSupport.java new file mode 100644 index 000000000000..318f68313afb --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OwlDatatypeSupport.java @@ -0,0 +1,78 @@ +package org.openmetadata.service.ontology; + +import java.net.URI; +import java.util.Set; +import org.apache.jena.datatypes.TypeMapper; +import org.apache.jena.vocabulary.OWL2; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import org.apache.jena.vocabulary.XSD; +import org.openmetadata.schema.type.OntologyLiteral; + +/** The OWL 2 datatype map; arbitrary XSD names are not necessarily OWL 2 DL datatypes. */ +public final class OwlDatatypeSupport { + private static final Set XSD_TYPES = + Set.of( + "string", + "normalizedString", + "token", + "language", + "Name", + "NCName", + "NMTOKEN", + "decimal", + "integer", + "nonNegativeInteger", + "nonPositiveInteger", + "positiveInteger", + "negativeInteger", + "long", + "int", + "short", + "byte", + "unsignedLong", + "unsignedInt", + "unsignedShort", + "unsignedByte", + "double", + "float", + "boolean", + "hexBinary", + "base64Binary", + "anyURI", + "dateTime", + "dateTimeStamp"); + private static final Set OTHER_TYPES = + Set.of( + RDFS.Literal.getURI(), + RDF.PlainLiteral.getURI(), + RDF.langString.getURI(), + RDF.xmlLiteral.getURI(), + OWL2.NS + "real", + OWL2.NS + "rational"); + + private OwlDatatypeSupport() {} + + public static boolean isSupported(final URI iri) { + final String value = iri == null ? "" : iri.toString(); + return OTHER_TYPES.contains(value) + || (value.startsWith(XSD.NS) && XSD_TYPES.contains(value.substring(XSD.NS.length()))); + } + + public static boolean isValidLiteral(final OntologyLiteral literal) { + if (literal == null || literal.getValue() == null) { + return false; + } + if (literal.getDatatypeIri() == null) { + return true; + } + final String iri = literal.getDatatypeIri().toString(); + final var datatype = TypeMapper.getInstance().getTypeByName(iri); + // The literal schema has no language tag, so it cannot encode language-tagged RDF literals. + return isSupported(literal.getDatatypeIri()) + && !RDF.langString.getURI().equals(iri) + && !RDF.PlainLiteral.getURI().equals(iri) + && datatype != null + && datatype.isValid(literal.getValue()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OwlProfileGuard.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OwlProfileGuard.java index 07cd2ef749e2..160aabab04a1 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OwlProfileGuard.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OwlProfileGuard.java @@ -18,19 +18,30 @@ import jakarta.ws.rs.BadRequestException; import java.net.URI; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; +import org.apache.jena.vocabulary.OWL2; import org.openmetadata.schema.api.data.OntologyProfileReport; import org.openmetadata.schema.entity.data.OntologyAxiom; -import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.entity.data.OntologyProperty; import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyLiteral; import org.openmetadata.schema.type.OntologyProfileViolation; import org.openmetadata.schema.type.OntologyProfileViolationCode; import org.openmetadata.schema.type.OntologyProfileViolationSeverity; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.RelationshipCharacteristic; +import org.openmetadata.service.rdf.OwlEntityDeclarations; public final class OwlProfileGuard { + private static final int MAX_EXPRESSION_DEPTH = 64; + private static final int MAX_EXPRESSION_STEPS = 10_000; private static final String RDF_NAMESPACE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; private static final String RDFS_NAMESPACE = "http://www.w3.org/2000/01/rdf-schema#"; private static final String OWL_NAMESPACE = "http://www.w3.org/2002/07/owl#"; @@ -38,20 +49,30 @@ public final class OwlProfileGuard { private static final Set RESERVED_NAMESPACES = Set.of(RDF_NAMESPACE, RDFS_NAMESPACE, OWL_NAMESPACE, XSD_NAMESPACE); - private final PropertySimplicity propertySimplicity; - private final VocabularyIndex vocabularyIndex; + private final Function properties; + private final Predicate nonSimple; + + public OwlProfileGuard(final Function properties) { + this(properties, iri -> isNonSimple(properties.apply(iri))); + } public OwlProfileGuard( - final PropertySimplicity propertySimplicity, final VocabularyIndex vocabularyIndex) { - this.propertySimplicity = propertySimplicity; - this.vocabularyIndex = vocabularyIndex; + final Function properties, final Predicate nonSimple) { + this.properties = Objects.requireNonNull(properties); + this.nonSimple = Objects.requireNonNull(nonSimple); } public OntologyProfileReport validate(final OntologyAxiom axiom) { final List violations = new ArrayList<>(); + if (axiom.getSubjectIri() == null) { + invalidAxiom(violations, "/subjectIri", "An axiom requires a subject IRI"); + } + validateAbsoluteIri(axiom.getSubjectIri(), "/subjectIri", violations); + validateAbsoluteIri(axiom.getPropertyIri(), "/propertyIri", violations); + validateAbsoluteIri(axiom.getTargetIri(), "/targetIri", violations); + validateLiteral(axiom.getLiteral(), "/literal", violations); validateReservedSubject(axiom, violations); validateAxiomShape(axiom, violations); - validatePunning(axiom, violations); final boolean isValid = violations.stream() .noneMatch( @@ -114,8 +135,10 @@ private void validateClassAssertion( rejectAssertionFields(axiom, violations); } - private static void validateObjectAssertion( + private void validateObjectAssertion( final OntologyAxiom axiom, final List violations) { + validatePropertyKind( + axiom.getPropertyIri(), OntologyPropertyKind.OBJECT, "/propertyIri", violations); final boolean isInvalid = axiom.getPropertyIri() == null || axiom.getTargetIri() == null; if (isInvalid) { invalidAxiom( @@ -131,8 +154,10 @@ private static void validateObjectAssertion( } } - private static void validateDataAssertion( + private void validateDataAssertion( final OntologyAxiom axiom, final List violations) { + validatePropertyKind( + axiom.getPropertyIri(), OntologyPropertyKind.DATA, "/propertyIri", violations); final boolean isInvalid = axiom.getPropertyIri() == null || axiom.getLiteral() == null; if (isInvalid) { invalidAxiom( @@ -163,8 +188,66 @@ private static void rejectAssertionFields( private void validateExpressions( final List expressions, final List violations) { final List safeExpressions = listOrEmpty(expressions); + final var pending = new ArrayDeque(); for (int index = 0; index < safeExpressions.size(); index++) { - validateExpression(safeExpressions.get(index), "/expressions/" + index, violations); + pending.add(new Expression(safeExpressions.get(index), "/expressions/" + index, 0, false)); + } + int visited = 0; + while (!pending.isEmpty()) { + final Expression current = pending.removeLast(); + if (++visited > MAX_EXPRESSION_STEPS + || pending.size() > MAX_EXPRESSION_STEPS + || current.depth() > MAX_EXPRESSION_DEPTH) { + invalidExpression( + violations, current.path(), "Expressions exceed 64 levels or 10000 nodes"); + break; + } + validateExpression(current.value(), current.path(), violations); + validateDataRange(current, violations); + scheduleChildren(current, pending); + } + } + + private void scheduleChildren(final Expression current, final ArrayDeque pending) { + final var value = current.value(); + if (value == null) { + return; + } + final var operands = listOrEmpty(value.getOperands()); + for (int index = 0; index < operands.size(); index++) { + pending.add( + new Expression( + operands.get(index), + current.path() + "/operands/" + index, + current.depth() + 1, + current.datatype())); + } + if (value.getFiller() != null) { + pending.add( + new Expression( + value.getFiller(), + current.path() + "/filler", + current.depth() + 1, + restrictionPropertyKind(value) == OntologyPropertyKind.DATA)); + } + } + + private static void validateDataRange( + final Expression current, final List violations) { + final var value = current.value(); + if (current.datatype() && value != null && value.getKind() != null) { + final boolean valid = + switch (value.getKind()) { + case NAMED_CLASS -> OwlDatatypeSupport.isSupported(value.getClassIri()); + case UNION, INTERSECTION -> true; + case ONE_OF, RESTRICTION -> false; + }; + if (!valid) { + invalidExpression( + violations, + current.path(), + "A data range requires supported OWL 2 datatypes or their intersection/union"); + } } } @@ -175,6 +258,9 @@ private void validateExpression( if (expression == null || expression.getKind() == null) { invalidExpression(violations, path, "Every class expression requires a kind"); } else { + validateExpressionIris(expression, path, violations); + OntologyExpressionShape.validate( + expression, path, message -> invalidExpression(violations, path, message)); switch (expression.getKind()) { case NAMED_CLASS -> validateNamedClass(expression, path, violations); case INTERSECTION, UNION -> validateOperands(expression, path, violations); @@ -184,6 +270,40 @@ private void validateExpression( } } + private static void validateExpressionIris( + final OntologyExpression expression, + final String path, + final List violations) { + validateAbsoluteIri(expression.getClassIri(), path + "/classIri", violations); + validateAbsoluteIri(expression.getPropertyIri(), path + "/propertyIri", violations); + validateAbsoluteIri(expression.getIndividualIri(), path + "/individualIri", violations); + listOrEmpty(expression.getIndividualIris()) + .forEach(iri -> validateAbsoluteIri(iri, path + "/individualIris", violations)); + validateLiteral(expression.getLiteral(), path + "/literal", violations); + } + + private static void validateLiteral( + final OntologyLiteral literal, + final String path, + final List violations) { + if (literal != null) { + validateAbsoluteIri(literal.getDatatypeIri(), path + "/datatypeIri", violations); + if (!OwlDatatypeSupport.isValidLiteral(literal)) { + invalidExpression( + violations, + path, + "A literal requires a supported OWL 2 datatype and a valid lexical value"); + } + } + } + + private static void validateAbsoluteIri( + final URI iri, final String path, final List violations) { + if (iri != null && !iri.isAbsolute()) { + invalidExpression(violations, path, "Ontology references must be absolute IRIs"); + } + } + private static void validateNamedClass( final OntologyExpression expression, final String path, @@ -202,18 +322,19 @@ private void validateOperands( invalidExpression( violations, path + "/operands", "Intersection and union require at least two operands"); } - for (int index = 0; index < operands.size(); index++) { - validateExpression(operands.get(index), path + "/operands/" + index, violations); - } } private static void validateEnumeration( final OntologyExpression expression, final String path, final List violations) { - if (nullOrEmpty(expression.getIndividualIris())) { + if (nullOrEmpty(expression.getIndividualIris()) + || expression.getIndividualIris().stream().anyMatch(Objects::isNull)) { invalidExpression( violations, path + "/individualIris", "An enumeration requires named individuals"); + } else if (expression.getIndividualIris().stream().map(URI::toString).distinct().count() + != expression.getIndividualIris().size()) { + invalidExpression(violations, path + "/individualIris", "Enumeration IRIs must be unique"); } } @@ -224,6 +345,11 @@ private void validateRestriction( if (expression.getPropertyIri() == null || expression.getRestrictionKind() == null) { invalidExpression(violations, path, "A restriction requires propertyIri and restrictionKind"); } else { + validatePropertyKind( + expression.getPropertyIri(), + restrictionPropertyKind(expression), + path + "/propertyIri", + violations); validateRestrictionValue(expression, path, violations); } } @@ -245,8 +371,6 @@ private void validateRestrictionFiller( final List violations) { if (expression.getFiller() == null) { invalidExpression(violations, path + "/filler", "This restriction requires a filler"); - } else { - validateExpression(expression.getFiller(), path + "/filler", violations); } } @@ -266,72 +390,65 @@ private void validateCardinality( final OntologyExpression expression, final String path, final List violations) { - if (expression.getCardinality() == null) { + if (expression.getCardinality() == null || expression.getCardinality() < 0) { invalidExpression( - violations, path + "/cardinality", "A cardinality restriction requires cardinality"); - } - if (expression.getFiller() != null) { - validateExpression(expression.getFiller(), path + "/filler", violations); + violations, + path + "/cardinality", + "A cardinality restriction requires a non-negative cardinality"); } - if (!propertySimplicity.isSimple(expression.getPropertyIri())) { + if (nonSimple.test(expression.getPropertyIri())) { addViolation( violations, OntologyProfileViolationCode.NON_SIMPLE_CARDINALITY, path + "/propertyIri", - "OWL 2 DL forbids cardinality restrictions over transitive or chained properties"); + "OWL 2 DL forbids cardinality restrictions over non-simple properties"); } } - private void validatePunning( - final OntologyAxiom axiom, final List violations) { - final boolean subjectIsClass = isClassAxiom(axiom.getAxiomType()); - final boolean conflicts = - subjectIsClass - ? vocabularyIndex.isIndividual(axiom.getSubjectIri()) - : axiom.getAxiomType() == OntologyAxiomType.CLASS_ASSERTION - && vocabularyIndex.isClass(axiom.getSubjectIri()); - if (conflicts) { - addViolation( - violations, - OntologyProfileViolationCode.ILLEGAL_PUNNING, - "/subjectIri", - "The subject IRI is already used with an incompatible class or individual role"); - } - validateExpressionPunning(axiom.getExpressions(), violations); + static boolean isNonSimple(final OntologyProperty property) { + return property != null + && (!nullOrEmpty(property.getPropertyChain()) + || (property.getCharacteristics() != null + && property.getCharacteristics().contains(RelationshipCharacteristic.TRANSITIVE))); } - private void validateExpressionPunning( - final List expressions, final List violations) { - for (final OntologyExpression expression : listOrEmpty(expressions)) { - validateExpressionPunning(expression, violations); + private void validatePropertyKind( + final URI iri, + final OntologyPropertyKind expected, + final String path, + final List violations) { + final OntologyProperty property = iri == null ? null : properties.apply(iri); + if (iri != null && OWL2.topDataProperty.getURI().equals(iri.toString())) { + invalidExpression( + violations, path, "OWL 2 DL permits topDataProperty only as a super data property"); } - } - - private void validateExpressionPunning( - final OntologyExpression expression, final List violations) { - if (expression != null - && expression.getClassIri() != null - && vocabularyIndex.isIndividual(expression.getClassIri())) { + if (property != null + && (property.getKind() == OntologyPropertyKind.ANNOTATION + || (expected != null && property.getKind() != expected))) { addViolation( violations, OntologyProfileViolationCode.ILLEGAL_PUNNING, - "/expressions/classIri", - "A named class IRI is already used as an individual"); - } - if (expression != null) { - validateExpressionPunning(expression.getOperands(), violations); - if (expression.getFiller() != null) { - validateExpressionPunning(expression.getFiller(), violations); - } + path, + "The assertion conflicts with the declared ontology property kind"); } } - private static boolean isClassAxiom(final OntologyAxiomType axiomType) { - final boolean isClassAxiom = - axiomType == OntologyAxiomType.SUBCLASS_OF - || axiomType == OntologyAxiomType.EQUIVALENT_CLASS - || axiomType == OntologyAxiomType.DISJOINT_WITH; - return isClassAxiom; + private OntologyPropertyKind restrictionPropertyKind(final OntologyExpression expression) { + final OntologyExpression filler = expression.getFiller(); + final OntologyProperty property = + expression.getPropertyIri() == null ? null : properties.apply(expression.getPropertyIri()); + if (expression.getLiteral() != null + || (expression.getIndividualIri() == null + && property != null + && property.getKind() == OntologyPropertyKind.DATA) + || (filler != null + && filler.getClassIri() != null + && OwlEntityDeclarations.isDatatype(filler.getClassIri().toString()))) { + return OntologyPropertyKind.DATA; + } + return expression.getIndividualIri() != null || filler != null + ? OntologyPropertyKind.OBJECT + : null; } private static boolean isReserved(final URI iri) { @@ -362,14 +479,5 @@ private static void addViolation( .withMessage(message)); } - @FunctionalInterface - public interface PropertySimplicity { - boolean isSimple(URI propertyIri); - } - - public interface VocabularyIndex { - boolean isClass(URI iri); - - boolean isIndividual(URI iri); - } + private record Expression(OntologyExpression value, String path, int depth, boolean datatype) {} } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/AssertedGraphTrackingRdfStorage.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/AssertedGraphTrackingRdfStorage.java index d0ec892c37dc..3c819fdd9d81 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/AssertedGraphTrackingRdfStorage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/AssertedGraphTrackingRdfStorage.java @@ -128,6 +128,15 @@ public void loadTurtleFile(final InputStream turtleStream, final String graphUri } } + @Override + public void replaceGraph(final String graph, final Model model) { + try { + delegate.replaceGraph(graph, model); + } finally { + assertedOnlyQueries.graphsWritten(Set.of(graph)); + } + } + /** * The named graphs an update can add triples to, or empty when they cannot be determined. * Writes to the default graph are left out: with a union default graph, Fuseki answers no query diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/GlossaryOntologyExporter.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/GlossaryOntologyExporter.java index a00375e26147..6b05f9ec3ade 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/GlossaryOntologyExporter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/GlossaryOntologyExporter.java @@ -30,7 +30,6 @@ import org.apache.jena.riot.RDFParser; import org.openmetadata.schema.entity.data.Glossary; import org.openmetadata.schema.entity.data.GlossaryTerm; -import org.openmetadata.schema.entity.data.OntologyAxiom; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.TermRelation; import org.openmetadata.service.Entity; @@ -96,7 +95,7 @@ private GlossaryOntologySource load(final UUID glossaryId, final boolean include final RelationshipTypeResolver resolver = new RelationshipTypeResolver(Entity.getCollectionDAO().relationshipTypeDAO()); return new GlossaryOntologySource( - glossary, terms, axioms(glossaryId), resolver.list(), externalTermIris); + glossary, terms, List.of(), resolver.list(), externalTermIris); } private Glossary glossary(final UUID glossaryId) { @@ -138,15 +137,6 @@ private String externalTermIri(final UUID termId) { return GlossaryTermClassIri.of(term, publicBaseUri); } - @SuppressWarnings("unchecked") - private List axioms(final UUID glossaryId) { - final EntityRepository repository = - (EntityRepository) Entity.getEntityRepository(Entity.ONTOLOGY_AXIOM); - final ListFilter filter = new ListFilter(Include.NON_DELETED); - filter.addQueryParam("glossaryId", glossaryId.toString()); - return repository.listAll(repository.getFields(""), filter); - } - private void mergeAnnex(final Model model, final UUID glossaryId) { final OntologyAnnexRow annex = annexDAO.findLatest(glossaryId); if (annex != null && !annex.canonicalNQuads().isBlank()) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/GlossaryOntologyModelBuilder.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/GlossaryOntologyModelBuilder.java index 140da2d92482..8a865eb4bcaa 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/GlossaryOntologyModelBuilder.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/GlossaryOntologyModelBuilder.java @@ -176,25 +176,12 @@ private static void addHierarchy( private static void addMappings( final Model model, final Resource term, final List mappings) { for (final ConceptMapping mapping : listOrEmpty(mappings)) { - final Property property = mappingProperty(mapping.getMappingType()); + final Property property = OntologyRdfVocabulary.mapping(mapping.getMappingType()); addObjectProperty( model, term, property, model.createResource(mapping.getConceptIri().toString())); } } - private static Property mappingProperty(final ConceptMapping.ConceptMappingType mappingType) { - final Property property = - switch (mappingType) { - case EXACT_MATCH -> SKOS.exactMatch; - case CLOSE_MATCH -> SKOS.closeMatch; - case BROAD_MATCH -> SKOS.broadMatch; - case NARROW_MATCH -> SKOS.narrowMatch; - case RELATED_MATCH -> SKOS.relatedMatch; - case SAME_AS -> OWL2.sameAs; - }; - return property; - } - /** * Link a concept to the data assets that store its instances. Every realization emits {@code * om:mappedTo} so a consumer can answer "what realizes this concept" in one hop, plus a diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyActiveGraph.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyActiveGraph.java new file mode 100644 index 000000000000..511de0929727 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyActiveGraph.java @@ -0,0 +1,92 @@ +package org.openmetadata.service.rdf; + +import java.util.List; +import java.util.UUID; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.jdbi3.CollectionDAO; +import org.openmetadata.service.ontology.OntologyReleaseReader; +import org.openmetadata.service.rdf.rebuild.RdfMutation; +import org.openmetadata.service.rdf.storage.RdfPayloadTooLargeException; +import org.openmetadata.service.rdf.storage.RdfStorageInterface; + +/** A disposable projection of the current active pointer; working edits never reach this graph. */ +public final class OntologyActiveGraph { + public static final String GRAPH_PREFIX = "https://open-metadata.org/graph/ontology/"; + private static final int PAGE_SIZE = 100; + private final CollectionDAO dao; + + public OntologyActiveGraph(final CollectionDAO dao) { + this.dao = dao; + } + + public void refresh(final UUID ontologyId, final RdfStorageInterface storage) { + final Model model = currentModel(ontologyId); + try { + storage.replaceGraph(graph(ontologyId), model); + } finally { + model.close(); + } + } + + public void rebuild(final RdfStorageInterface storage) { + String after = ""; + List page; + do { + page = dao.ontologyStateDAO().listActive(after, PAGE_SIZE); + page.forEach(id -> refreshDuringRebuild(UUID.fromString(id), storage)); + if (!page.isEmpty()) { + after = page.getLast(); + } + } while (page.size() == PAGE_SIZE); + storage.getAllGraphs().stream() + .filter(iri -> iri.startsWith(GRAPH_PREFIX)) + .map(iri -> UUID.fromString(iri.substring(GRAPH_PREFIX.length()))) + .filter(id -> !isActive(id)) + .forEach(id -> refreshDuringRebuild(id, storage)); + } + + private void refreshDuringRebuild(final UUID ontologyId, final RdfStorageInterface storage) { + dao.useHandle( + handle -> RdfLiveWriteStore.withWriterLock(handle, () -> refresh(ontologyId, storage))); + } + + private Model currentModel(final UUID ontologyId) { + if (!isActive(ontologyId)) { + return ModelFactory.createDefaultModel(); + } + final var state = dao.ontologyStateDAO().get(ontologyId); + final var release = dao.ontologyReleaseDAO().findEntityById(state.getActiveReleaseId()); + final var closure = new OntologyReleaseReader(dao).read(release, ignored -> {}); + return new OntologyReleaseProjection().build(release, closure); + } + + private boolean isActive(final UUID ontologyId) { + final var roots = dao.ontologyEntityDAO(); + final String json = + roots.findById(roots.getTableName(), ontologyId, roots.getCondition(Include.NON_DELETED)); + final var state = dao.ontologyStateDAO().get(ontologyId); + return json != null && state != null && state.getActiveReleaseId() != null; + } + + public static void requireProjectable( + final OntologyRelease release, final List closure) { + final Model model = new OntologyReleaseProjection().build(release, closure); + try { + RdfMutation.GraphReplace.capture(graph(release.getOntology().getId()), model); + } catch (RdfPayloadTooLargeException tooLarge) { + throw new BadRequestException( + "Ontology RDF projection exceeds the 16 MiB rebuild journal limit"); + } finally { + model.close(); + } + } + + public static String graph(final UUID ontologyId) { + return GRAPH_PREFIX + ontologyId; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyExpressionRdfWriter.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyExpressionRdfWriter.java index 9c7b4223bb32..3a7885c0c992 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyExpressionRdfWriter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyExpressionRdfWriter.java @@ -174,14 +174,30 @@ private void addRestrictionValue( } } - /** A data restriction's filler is a datatype, which OWL 2 never declares as a class. */ private Resource filler( final Model model, final OntologyExpression filler, final boolean isData) { - final Resource resource = - isData && isDatatypeFiller(filler) - ? model.createResource(filler.getClassIri().toString()) - : expression(model, filler); - return resource; + return isData ? dataRange(model, filler) : expression(model, filler); + } + + private Resource dataRange(final Model model, final OntologyExpression value) { + return switch (value.getKind()) { + case NAMED_CLASS -> { + final Resource datatype = model.createResource(value.getClassIri().toString()); + OwlEntityDeclarations.declare(model, datatype, EntityKind.DATATYPE); + yield datatype; + } + case UNION, INTERSECTION -> model + .createResource() + .addProperty(RDF.type, RDFS.Datatype) + .addProperty( + value.getKind() == OntologyExpressionKind.UNION ? OWL2.unionOf : OWL2.intersectionOf, + model.createList( + value.getOperands().stream() + .map(operand -> dataRange(model, operand)) + .iterator())); + case ONE_OF, RESTRICTION -> throw new IllegalArgumentException( + "Unsupported data range: " + value.getKind()); + }; } private static void addHasValue( @@ -233,7 +249,7 @@ private static Resource individual(final Model model, final String iri) { return model.createResource(iri); } - private static Literal literal(final Model model, final OntologyLiteral literal) { + static Literal literal(final Model model, final OntologyLiteral literal) { final Literal value = literal.getDatatypeIri() == null ? model.createLiteral(literal.getValue()) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyPropertyRdfWriter.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyPropertyRdfWriter.java new file mode 100644 index 000000000000..07882cb1526b --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyPropertyRdfWriter.java @@ -0,0 +1,136 @@ +package org.openmetadata.service.rdf; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; + +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.RDFNode; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.OWL2; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.OntologyLiteral; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.service.rdf.OwlEntityDeclarations.EntityKind; + +final class OntologyPropertyRdfWriter { + private OntologyPropertyRdfWriter() {} + + static Resource write( + final Model model, final OntologyProperty definition, final String enumIri) { + final OntologyPropertyKind kind = definition.getKind(); + final Resource property = model.createResource(definition.getIri().toString()); + final Resource declaration = + switch (kind) { + case OBJECT -> OWL2.ObjectProperty; + case DATA -> OWL2.DatatypeProperty; + case ANNOTATION -> OWL2.AnnotationProperty; + }; + property.addProperty(RDF.type, declaration); + listOrEmpty(definition.getDomain()) + .forEach( + iri -> + property.addProperty( + RDFS.domain, + reference( + model, + iri, + kind == OntologyPropertyKind.ANNOTATION ? null : EntityKind.CLASS))); + listOrEmpty(definition.getRange()) + .forEach(iri -> property.addProperty(RDFS.range, reference(model, iri, rangeKind(kind)))); + if (!nullOrEmpty(definition.getCharacteristics())) { + definition + .getCharacteristics() + .forEach( + characteristic -> + property.addProperty( + RDF.type, OntologyRdfVocabulary.characteristic(characteristic))); + } + if (definition.getInverse() != null) { + property.addProperty( + OWL2.inverseOf, reference(model, definition.getInverse(), EntityKind.OBJECT_PROPERTY)); + } + listOrEmpty(definition.getDisjointWith()) + .forEach( + iri -> + property.addProperty( + OWL2.propertyDisjointWith, + reference( + model, + iri, + kind == OntologyPropertyKind.DATA + ? EntityKind.DATA_PROPERTY + : EntityKind.OBJECT_PROPERTY))); + if (!nullOrEmpty(definition.getPropertyChain())) { + property.addProperty( + OWL2.propertyChainAxiom, + model.createList( + definition.getPropertyChain().stream() + .map(iri -> (RDFNode) reference(model, iri, EntityKind.OBJECT_PROPERTY)) + .iterator())); + } + writeDataMetadata(model, property, definition, enumIri); + return property; + } + + private static void writeDataMetadata( + final Model model, + final Resource property, + final OntologyProperty definition, + final String enumIri) { + if (!nullOrEmpty(definition.getEnumValues())) { + final Resource range = + model + .createResource() + .addProperty(RDF.type, RDFS.Datatype) + .addProperty( + OWL2.oneOf, + model.createList( + definition.getEnumValues().stream() + .map(value -> enumLiteral(model, value)) + .iterator())); + // OWL API 5.5.1 consumes an anonymous data range twice when its range axiom is annotated. + final Resource datatype = + model + .createResource(enumIri) + .addProperty(RDF.type, RDFS.Datatype) + .addProperty(OWL2.equivalentClass, range); + property.addProperty(RDFS.range, datatype); + } + if (definition.getUnit() != null) { + OwlEntityDeclarations.declare( + model, OntologyRdfVocabulary.UNIT, EntityKind.ANNOTATION_PROPERTY); + property.addProperty(OntologyRdfVocabulary.UNIT, definition.getUnit()); + } + if (Boolean.TRUE.equals(definition.getIsIdentifier())) { + OwlEntityDeclarations.declare( + model, OntologyRdfVocabulary.IDENTIFIER_INTENT, EntityKind.ANNOTATION_PROPERTY); + property.addLiteral(OntologyRdfVocabulary.IDENTIFIER_INTENT, true); + } + } + + private static RDFNode enumLiteral(final Model model, final OntologyLiteral value) { + if (value.getDatatypeIri() != null) { + reference(model, value.getDatatypeIri(), EntityKind.DATATYPE); + } + return OntologyExpressionRdfWriter.literal(model, value); + } + + private static EntityKind rangeKind(final OntologyPropertyKind kind) { + return switch (kind) { + case OBJECT -> EntityKind.CLASS; + case DATA -> EntityKind.DATATYPE; + case ANNOTATION -> null; + }; + } + + private static Resource reference(final Model model, final URI iri, final EntityKind kind) { + final Resource resource = model.createResource(iri.toString()); + if (kind != null) { + OwlEntityDeclarations.declare(model, resource, kind); + } + return resource; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyRdfVocabulary.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyRdfVocabulary.java new file mode 100644 index 000000000000..e9ddd9c837ef --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyRdfVocabulary.java @@ -0,0 +1,41 @@ +package org.openmetadata.service.rdf; + +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; +import org.apache.jena.vocabulary.OWL2; +import org.apache.jena.vocabulary.SKOS; +import org.openmetadata.schema.api.data.ConceptMapping; +import org.openmetadata.schema.type.RelationshipCharacteristic; + +final class OntologyRdfVocabulary { + static final Property UNIT = + ResourceFactory.createProperty("https://open-metadata.org/ontology/unitAnnotation"); + static final Property IDENTIFIER_INTENT = + ResourceFactory.createProperty("https://open-metadata.org/ontology/identifierIntent"); + + private OntologyRdfVocabulary() {} + + static Resource characteristic(final RelationshipCharacteristic characteristic) { + return switch (characteristic) { + case SYMMETRIC -> OWL2.SymmetricProperty; + case TRANSITIVE -> OWL2.TransitiveProperty; + case FUNCTIONAL -> OWL2.FunctionalProperty; + case INVERSE_FUNCTIONAL -> OWL2.InverseFunctionalProperty; + case REFLEXIVE -> OWL2.ReflexiveProperty; + case IRREFLEXIVE -> OWL2.IrreflexiveProperty; + case ASYMMETRIC -> OWL2.AsymmetricProperty; + }; + } + + static Property mapping(final ConceptMapping.ConceptMappingType type) { + return switch (type) { + case EXACT_MATCH -> SKOS.exactMatch; + case CLOSE_MATCH -> SKOS.closeMatch; + case BROAD_MATCH -> SKOS.broadMatch; + case NARROW_MATCH -> SKOS.narrowMatch; + case RELATED_MATCH -> SKOS.relatedMatch; + case SAME_AS -> OWL2.sameAs; + }; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyReleaseExporter.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyReleaseExporter.java new file mode 100644 index 000000000000..7cdebc8c3ea5 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyReleaseExporter.java @@ -0,0 +1,31 @@ +package org.openmetadata.service.rdf; + +import java.util.List; +import java.util.function.Consumer; +import org.apache.jena.rdf.model.Model; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.service.jdbi3.OntologyReleaseRepository; +import org.openmetadata.service.ontology.OntologyReleaseReader; + +/** Exports authorized, verified release content independently of the optional RDF service. */ +public final class OntologyReleaseExporter { + private final OntologyReleaseReader reader; + + public OntologyReleaseExporter(final OntologyReleaseRepository releases) { + reader = new OntologyReleaseReader(releases.getDaoCollection()); + } + + public String export( + final OntologyRelease release, + final RdfSerializationFormat format, + final Consumer> authorize) { + final var closure = reader.read(release, authorize); + final Model model = new OntologyReleaseProjection().build(release, closure); + try { + return RdfGraphSerializer.asString(model, format); + } finally { + model.close(); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyReleaseProjection.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyReleaseProjection.java new file mode 100644 index 000000000000..3f9364e51dbc --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyReleaseProjection.java @@ -0,0 +1,195 @@ +package org.openmetadata.service.rdf; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.RDFNode; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.Statement; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.OWL2; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import org.apache.jena.vocabulary.SKOS; +import org.apache.jena.vocabulary.XSD; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.ontology.OntologyReleaseManifest; +import org.openmetadata.service.ontology.OntologyReleaseReader; +import org.openmetadata.service.rdf.OwlEntityDeclarations.EntityKind; + +/** The ontology-release-v1 RDF projection reads sealed SQL members without any live lookup. */ +public final class OntologyReleaseProjection { + public Model build(final OntologyRelease release, final List members) { + OntologyReleaseManifest.verify(release, members); + final List definitions = + members.stream() + .map( + member -> new Definition(member, OntologyReleaseReader.definition(release, member))) + .toList(); + final Ontology ontology = root(definitions); + final Set dataProperties = + definitions.stream() + .map(Definition::value) + .filter(OntologyProperty.class::isInstance) + .map(OntologyProperty.class::cast) + .filter(property -> property.getKind() == OntologyPropertyKind.DATA) + .map(property -> property.getIri().toString()) + .collect(Collectors.toUnmodifiableSet()); + final OntologyExpressionRdfWriter expressions = + new OntologyExpressionRdfWriter(dataProperties::contains); + final Model model = model(ontology); + try { + definitions.forEach(definition -> write(model, release, definition, expressions)); + return model; + } catch (RuntimeException exception) { + model.close(); + throw exception; + } + } + + private static Ontology root(final List definitions) { + final List roots = + definitions.stream() + .map(Definition::value) + .filter(Ontology.class::isInstance) + .map(Ontology.class::cast) + .toList(); + if (roots.size() != 1) { + throw new BadRequestException("An ontology release requires exactly one ontology root"); + } + return roots.getFirst(); + } + + private static Model model(final Ontology ontology) { + final Model model = ModelFactory.createDefaultModel(); + model.setNsPrefixes( + Map.of( + "owl", + OWL2.NS, + "rdf", + RDF.uri, + "rdfs", + RDFS.uri, + "skos", + SKOS.uri, + "xsd", + XSD.NS, + "dct", + DCTerms.NS)); + listOrEmpty(ontology.getPrefixes()) + .forEach(prefix -> model.setNsPrefix(prefix.getPrefix(), prefix.getNamespace().toString())); + OwlEntityDeclarations.declare(model, DCTerms.source, EntityKind.ANNOTATION_PROPERTY); + return model; + } + + private static void write( + final Model target, + final OntologyRelease release, + final Definition definition, + final OntologyExpressionRdfWriter expressions) { + final Model fragment = ModelFactory.createDefaultModel(); + try { + final String memberIri = + release.getVersionIri() + + ":" + + definition.member().getMemberKind() + + ":" + + definition.member().getMemberId(); + final Resource subject = + switch (definition.value()) { + case Ontology ontology -> fragment + .createResource(ontology.getIri().toString()) + .addProperty(RDF.type, OWL2.Ontology) + .addProperty( + OWL2.versionIRI, fragment.createResource(release.getVersionIri().toString())); + case OntologyConcept concept -> concept(fragment, concept); + case OntologyProperty property -> OntologyPropertyRdfWriter.write( + fragment, property, memberIri + ":values"); + case OntologyAxiom axiom -> { + expressions.write(fragment, axiom); + yield fragment.createResource(axiom.getSubjectIri().toString()); + } + default -> throw new BadRequestException("Unsupported ontology definition"); + }; + if (!(definition.value() instanceof OntologyAxiom)) { + labels(subject, definition.value()); + } + target.add(fragment); + if (!(definition.value() instanceof Ontology)) { + final Resource source = target.createResource(memberIri); + fragment + .listStatements(subject, null, (RDFNode) null) + .toList() + .forEach(statement -> annotate(target, statement, source)); + } + } finally { + fragment.close(); + } + } + + private static Resource concept(final Model model, final OntologyConcept concept) { + final Resource subject = model.createResource(concept.getIri().toString()); + if (concept.getConceptType() != OntologyConceptType.SKOS_CONCEPT) { + subject.addProperty(RDF.type, OWL2.Class); + } + if (concept.getConceptType() != OntologyConceptType.OWL_CLASS) { + OwlEntityDeclarations.declare(model, subject, EntityKind.NAMED_INDIVIDUAL); + OwlEntityDeclarations.declare(model, SKOS.Concept, EntityKind.CLASS); + subject.addProperty(RDF.type, SKOS.Concept); + } + if (!nullOrEmpty(concept.getSynonyms())) { + OwlEntityDeclarations.declare(model, SKOS.altLabel, EntityKind.ANNOTATION_PROPERTY); + concept.getSynonyms().forEach(synonym -> subject.addProperty(SKOS.altLabel, synonym)); + } + listOrEmpty(concept.getConceptMappings()) + .forEach( + mapping -> { + final var predicate = OntologyRdfVocabulary.mapping(mapping.getMappingType()); + OwlEntityDeclarations.declare(model, predicate, EntityKind.OBJECT_PROPERTY); + OwlEntityDeclarations.declare(model, subject, EntityKind.NAMED_INDIVIDUAL); + final Resource mapped = model.createResource(mapping.getConceptIri().toString()); + OwlEntityDeclarations.declare(model, mapped, EntityKind.NAMED_INDIVIDUAL); + subject.addProperty(predicate, mapped); + }); + return subject; + } + + private static void labels(final Resource subject, final OntologyDefinition definition) { + subject.addProperty( + RDFS.label, + nullOrEmpty(definition.getDisplayName()) + ? definition.getName() + : definition.getDisplayName()); + if (!nullOrEmpty(definition.getDescription())) { + subject.addProperty(RDFS.comment, definition.getDescription()); + } + } + + private static void annotate( + final Model model, final Statement statement, final Resource source) { + model + .createResource() + .addProperty(RDF.type, OWL2.Axiom) + .addProperty(OWL2.annotatedSource, statement.getSubject()) + .addProperty(OWL2.annotatedProperty, statement.getPredicate()) + .addProperty(OWL2.annotatedTarget, statement.getObject()) + .addProperty(DCTerms.source, source); + } + + private record Definition(OntologyReleaseMember member, OntologyDefinition value) {} +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologySparqlQueryService.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologySparqlQueryService.java index 99d4214c6d6d..2300e2d50529 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologySparqlQueryService.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologySparqlQueryService.java @@ -40,7 +40,7 @@ public OntologySparqlQueryService(final GlossaryOntologyExporter exporter) { this(exporter::materialize, new SparqlFederationGuard(null)); } - OntologySparqlQueryService( + public OntologySparqlQueryService( final OntologyModelProvider modelProvider, final SparqlFederationGuard federationGuard) { this.modelProvider = Objects.requireNonNull(modelProvider); queryValidator = new OntologySparqlQueryValidator(Objects.requireNonNull(federationGuard)); @@ -221,7 +221,7 @@ public record QueryResult(String body, String format, String mediaType) {} record ValidatedQuery(Query query, ResultFormat format, int timeoutMillis, Inference inference) {} @FunctionalInterface - interface OntologyModelProvider { + public interface OntologyModelProvider { Model materialize(UUID glossaryId, boolean includeRelations); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OwlEntityDeclarations.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OwlEntityDeclarations.java index 2b93b143b709..5d4668e19cc4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OwlEntityDeclarations.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OwlEntityDeclarations.java @@ -44,12 +44,24 @@ public final class OwlEntityDeclarations { OWL2.NS + "real", OWL2.NS + "rational"); private static final Map SKOS_KINDS = skosKinds(); + private static final Set BUILT_IN_ANNOTATIONS = + Set.of( + RDFS.label.getURI(), + RDFS.comment.getURI(), + RDFS.seeAlso.getURI(), + RDFS.isDefinedBy.getURI(), + OWL2.versionInfo.getURI(), + OWL2.priorVersion.getURI(), + OWL2.backwardCompatibleWith.getURI(), + OWL2.incompatibleWith.getURI(), + OWL2.deprecated.getURI()); private OwlEntityDeclarations() {} /** The kinds of entity an OWL 2 declaration names. */ public enum EntityKind { CLASS(OWL2.Class), + DATATYPE(RDFS.Datatype), OBJECT_PROPERTY(OWL2.ObjectProperty), DATA_PROPERTY(OWL2.DatatypeProperty), ANNOTATION_PROPERTY(OWL2.AnnotationProperty), @@ -94,6 +106,20 @@ public static boolean isDatatype(final String iri) { return iri != null && (iri.startsWith(XSD.NS) || NON_XSD_DATATYPES.contains(iri)); } + /** Fixed vocabulary kinds can be resolved without redefining or fetching remote terms. */ + public static EntityKind vocabularyKind(final String iri) { + if (OWL2.topObjectProperty.getURI().equals(iri) + || OWL2.bottomObjectProperty.getURI().equals(iri)) { + return EntityKind.OBJECT_PROPERTY; + } + if (OWL2.topDataProperty.getURI().equals(iri) || OWL2.bottomDataProperty.getURI().equals(iri)) { + return EntityKind.DATA_PROPERTY; + } + return BUILT_IN_ANNOTATIONS.contains(iri) + ? EntityKind.ANNOTATION_PROPERTY + : SKOS_KINDS.get(iri); + } + private static boolean isSkos(final String iri) { return iri != null && iri.startsWith(SKOS.uri); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfExcludedEntities.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfExcludedEntities.java index 43c94c472b85..c5cb7294f346 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfExcludedEntities.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfExcludedEntities.java @@ -14,12 +14,13 @@ package org.openmetadata.service.rdf; import java.util.Set; +import org.openmetadata.service.Entity; /** - * Entity types that are never represented in the RDF knowledge graph — neither as nodes nor as the - * endpoint of any relationship edge. The same policy gates both the bulk reindex ({@code + * Entity types excluded from generic metadata projection and its relationship edges. Native + * ontologies use their active-release graphs instead. The same policy gates both bulk reindex ({@code * RdfIndexApp} / {@code RdfBatchProcessor}) and the live per-write sync ({@link RdfUpdater}), so an - * excluded type stays out of RDF "at all" rather than reappearing between weekly rebuilds. + * excluded working definition cannot reappear between rebuilds. * *

{@code aiChart} is a Collate AI-generated chart entity that is not part of the metadata * knowledge graph; its rows additionally fail to deserialize during the RDF read pass, so each one @@ -29,7 +30,15 @@ public final class RdfExcludedEntities { private static final String AI_CHART = "aiChart"; - public static final Set EXCLUDED_ENTITY_TYPES = Set.of(AI_CHART); + // Native ontology meaning is published only through the active release's dedicated graph. + public static final Set EXCLUDED_ENTITY_TYPES = + Set.of( + AI_CHART, + Entity.ONTOLOGY, + Entity.ONTOLOGY_CONCEPT, + Entity.ONTOLOGY_PROPERTY, + Entity.ONTOLOGY_AXIOM, + Entity.ONTOLOGY_RELEASE); private RdfExcludedEntities() {} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfLiveWrite.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfLiveWrite.java index dc5e53b32c7a..62a1aaa80db1 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfLiveWrite.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfLiveWrite.java @@ -25,6 +25,7 @@ /** Replayable live hooks. Entity payloads stay in the authoritative metadata tables. */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "operation") @JsonSubTypes({ + @JsonSubTypes.Type(value = RdfLiveWrite.OntologyGraph.class, name = "ontologyGraph"), @JsonSubTypes.Type(value = RdfLiveWrite.EntityUpdate.class, name = "entity"), @JsonSubTypes.Type(value = RdfLiveWrite.EntityDelete.class, name = "delete"), @JsonSubTypes.Type(value = RdfLiveWrite.RelationshipChange.class, name = "relationship"), @@ -33,6 +34,13 @@ public sealed interface RdfLiveWrite { void apply(RdfRepository repository); + record OntologyGraph(UUID ontologyId) implements RdfLiveWrite { + @Override + public void apply(final RdfRepository repository) { + repository.refreshOntologyGraph(ontologyId); + } + } + record EntityUpdate(String entityType, UUID entityId) implements RdfLiveWrite { public static EntityUpdate capture(final EntityInterface entity) { return new EntityUpdate(Entity.getEntityTypeFromObject(entity), entity.getId()); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfLiveWriteStore.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfLiveWriteStore.java index 0de6a1207af4..7b50795d06e7 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfLiveWriteStore.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfLiveWriteStore.java @@ -27,6 +27,8 @@ public final class RdfLiveWriteStore { private static final long MAX_HEALTHY_LAG_MILLIS = Duration.ofSeconds(30).toMillis(); private static final int MAX_ERROR_LENGTH = 8192; private static final int MAX_DELIVERY_ATTEMPTS = 10; + private static final String WRITER_LOCK = + "SELECT id FROM rdf_live_write_guard WHERE id = 'drain' FOR UPDATE"; private static final String RECORD_ENQUEUED = "UPDATE rdf_projection_health SET enqueuedWatermark = GREATEST(enqueuedWatermark, ?) " + "WHERE id = 'active'"; @@ -40,11 +42,16 @@ public RdfLiveWriteStore(final Jdbi jdbi, final Clock clock) { public void enqueue(final String payload) { jdbi.useTransaction( - TransactionIsolationLevel.READ_COMMITTED, - handle -> { - lockEnqueue(handle); - handle.execute(RECORD_ENQUEUED, insert(handle, payload)); - }); + TransactionIsolationLevel.READ_COMMITTED, handle -> enqueue(handle, payload)); + } + + /** Enqueue last in the caller's transaction so delivery and the metadata change commit together. */ + public void enqueue(final Handle handle, final String payload) { + if (!handle.isInTransaction()) { + throw new IllegalStateException("RDF outbox writes require an open metadata transaction"); + } + lockEnqueue(handle); + handle.execute(RECORD_ENQUEUED, insert(handle, payload)); } /** @@ -119,8 +126,7 @@ public boolean processNext(final Consumer write) { handle -> { final boolean ownsWriter = handle - .createQuery( - "SELECT id FROM rdf_live_write_guard WHERE id = 'drain' FOR UPDATE SKIP LOCKED") + .createQuery(WRITER_LOCK + " SKIP LOCKED") .mapTo(String.class) .findOne() .isPresent(); @@ -128,6 +134,16 @@ public boolean processNext(final Consumer write) { }); } + /** Excludes queued writes from the projection read through its replacement during a rebuild. */ + public static void withWriterLock(final Handle handle, final Runnable write) { + handle.useTransaction( + TransactionIsolationLevel.READ_COMMITTED, + transaction -> { + transaction.createQuery(WRITER_LOCK).mapTo(String.class).one(); + write.run(); + }); + } + private boolean processHead(final Handle handle, final Consumer write) { final Entry entry = head(handle); if (entry == null || entry.nextAttemptAt() > clock.millis()) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfProjectionLoaders.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfProjectionLoaders.java index 7185b027c9a5..c48f543a94e4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfProjectionLoaders.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfProjectionLoaders.java @@ -15,6 +15,7 @@ import java.util.Optional; import java.util.UUID; import java.util.function.BiFunction; +import java.util.function.Supplier; import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.LineageDetails; @@ -29,7 +30,8 @@ */ record RdfProjectionLoaders( BiFunction> entityLoader, - LineageDetailsLoader lineageDetailsLoader) { + LineageDetailsLoader lineageDetailsLoader, + Supplier ontologyGraphs) { @FunctionalInterface interface LineageDetailsLoader { @@ -42,7 +44,9 @@ record StoredLineageEdge(LineageDetails details) {} static RdfProjectionLoaders defaults() { return new RdfProjectionLoaders( - RdfProjectionLoaders::loadEntity, RdfProjectionLoaders::loadLineageDetails); + RdfProjectionLoaders::loadEntity, + RdfProjectionLoaders::loadLineageDetails, + () -> new OntologyActiveGraph(Entity.getCollectionDAO())); } private static EntityInterface loadEntity(final String entityType, final UUID entityId) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfRepository.java index d8315e89ae92..29edb74b6101 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfRepository.java @@ -501,6 +501,14 @@ public void refreshEntity(final String entityType, final UUID entityId) { createOrUpdate(entity); } + public void refreshOntologyGraph(final UUID ontologyId) { + projectionLoaders.ontologyGraphs().get().refresh(ontologyId, storageService); + } + + public void rebuildOntologyGraphs() { + projectionLoaders.ontologyGraphs().get().rebuild(storageService); + } + /** * Projects a lineage edge with the details stored for it now, including column lineage, so live * edits and reindexing write the same triples. A replay that finds the edge gone, such as an add diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfUpdater.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfUpdater.java index 20cf940c4e19..bb26f8c08b9e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfUpdater.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RdfUpdater.java @@ -21,8 +21,10 @@ import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.EntityRelationship; import org.openmetadata.schema.type.Relationship; +import org.openmetadata.schema.utils.JsonUtils; import org.openmetadata.service.Entity; import org.openmetadata.service.config.AsyncOperationsConfiguration; +import org.openmetadata.service.jdbi3.CollectionDAO; import org.openmetadata.service.util.AsyncService; import org.openmetadata.service.util.PostCommitActionQueue; @@ -67,6 +69,16 @@ public static boolean isEnabled() { return writeQueue != null; } + public static void enqueueOntologyGraph(final CollectionDAO dao, final UUID ontologyId) { + final RdfLiveWriteStore store = liveWriteStore; + if (store != null) { + dao.useHandle( + handle -> + store.enqueue( + handle, JsonUtils.pojoToJson(new RdfLiveWrite.OntologyGraph(ontologyId)))); + } + } + public static synchronized void disable() { stop(); RdfRepository.reset(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RelationshipTypeRdfWriter.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RelationshipTypeRdfWriter.java index ee96b633bc16..4d9814495992 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RelationshipTypeRdfWriter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/RelationshipTypeRdfWriter.java @@ -77,24 +77,10 @@ private void writeObjectProperty( private static void addCharacteristics( final Resource property, final Set characteristics) { for (final RelationshipCharacteristic characteristic : setOrEmpty(characteristics)) { - property.addProperty(RDF.type, characteristicResource(characteristic)); + property.addProperty(RDF.type, OntologyRdfVocabulary.characteristic(characteristic)); } } - private static Resource characteristicResource(final RelationshipCharacteristic characteristic) { - final Resource resource = - switch (characteristic) { - case SYMMETRIC -> OWL2.SymmetricProperty; - case TRANSITIVE -> OWL2.TransitiveProperty; - case FUNCTIONAL -> OWL2.FunctionalProperty; - case INVERSE_FUNCTIONAL -> OWL2.InverseFunctionalProperty; - case REFLEXIVE -> OWL2.ReflexiveProperty; - case IRREFLEXIVE -> OWL2.IrreflexiveProperty; - case ASYMMETRIC -> OWL2.AsymmetricProperty; - }; - return resource; - } - private static void addClassReferences( final Model model, final Resource property, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/rebuild/RdfMutation.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/rebuild/RdfMutation.java index c61ed7396e6d..30e13f04d1ee 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/rebuild/RdfMutation.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/rebuild/RdfMutation.java @@ -34,6 +34,7 @@ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "operation") @JsonSubTypes({ + @JsonSubTypes.Type(value = RdfMutation.GraphReplace.class, name = "replaceGraph"), @JsonSubTypes.Type(value = RdfMutation.EntityWrite.class, name = "entity"), @JsonSubTypes.Type(value = RdfMutation.EntityBatch.class, name = "entities"), @JsonSubTypes.Type(value = RdfMutation.EntityDelete.class, name = "delete"), @@ -173,6 +174,17 @@ public void apply(final RdfStorageInterface storage) { } } + record GraphReplace(String graph, byte[] rdf) implements RdfMutation { + public static GraphReplace capture(final String graph, final Model model) { + return new GraphReplace(graph, encode(model)); + } + + @Override + public void apply(final RdfStorageInterface storage) { + withModel(rdf, model -> storage.replaceGraph(graph, model)); + } + } + private static byte[] encode(final Model model) { final ByteArrayOutputStream output = new ByteArrayOutputStream() { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/rebuild/RebuildingRdfStorage.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/rebuild/RebuildingRdfStorage.java index d806d8ad8c9c..ae628077860f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/rebuild/RebuildingRdfStorage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/rebuild/RebuildingRdfStorage.java @@ -124,6 +124,13 @@ public void loadTurtleFile(final InputStream input, final String graph) { () -> new RdfMutation.Turtle(body, graph)); } + @Override + public void replaceGraph(final String graph, final Model model) { + mutate( + storage -> storage.replaceGraph(graph, model), + () -> RdfMutation.GraphReplace.capture(graph, model)); + } + private static byte[] readTurtle(final InputStream input) { try { final byte[] body = input.readNBytes(MAX_TURTLE_BYTES + 1); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/ForwardingRdfStorage.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/ForwardingRdfStorage.java index a032339cbf81..39ef2522be4f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/ForwardingRdfStorage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/ForwardingRdfStorage.java @@ -85,6 +85,11 @@ public void loadTurtleFile(InputStream input, String graph) { delegate().loadTurtleFile(input, graph); } + @Override + public void replaceGraph(final String graph, final Model model) { + delegate().replaceGraph(graph, model); + } + @Override public List getAllGraphs() { return delegate().getAllGraphs(); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/InferenceInvalidatingRdfStorage.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/InferenceInvalidatingRdfStorage.java index 3a202bd6be30..0512417c3888 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/InferenceInvalidatingRdfStorage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/InferenceInvalidatingRdfStorage.java @@ -91,6 +91,11 @@ public void loadTurtleFile(final InputStream turtleStream, final String graphUri mutate(() -> delegate.loadTurtleFile(turtleStream, graphUri)); } + @Override + public void replaceGraph(final String graph, final Model model) { + mutate(() -> delegate.replaceGraph(graph, model)); + } + @Override public void clearGraph(final String graphUri) { mutate(() -> delegate.clearGraph(graphUri)); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/JenaFusekiStorage.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/JenaFusekiStorage.java index 3abffe9db390..991be75d292c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/JenaFusekiStorage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/JenaFusekiStorage.java @@ -1651,6 +1651,21 @@ public void loadTurtleFile(java.io.InputStream turtleStream, String graphUri) { } } + @Override + public void replaceGraph(final String graphUri, final Model model) { + throwIfCircuitOpen("replaceGraph"); + final String graph = requireAbsoluteGraphUri(graphUri).toString(); + try { + runWriteWithRetry(() -> connection.put(graph, model), "replaceGraph"); + recordSuccess(); + } catch (RuntimeException failure) { + if (isCircuitBreakerFailure(failure)) { + recordFailure(); + } + throw failure; + } + } + @Override public List getAllGraphs() { throwIfCircuitOpen("getAllGraphs"); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/RdfStorageInterface.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/RdfStorageInterface.java index 2bd2df1b3ab8..947872727098 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/RdfStorageInterface.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/RdfStorageInterface.java @@ -1,11 +1,18 @@ package org.openmetadata.service.rdf.storage; +import java.net.URI; import java.util.List; import java.util.OptionalLong; import java.util.Set; import java.util.UUID; import lombok.Getter; +import org.apache.jena.graph.NodeFactory; import org.apache.jena.rdf.model.Model; +import org.apache.jena.sparql.core.Quad; +import org.apache.jena.sparql.modify.request.QuadDataAcc; +import org.apache.jena.sparql.modify.request.UpdateDataInsert; +import org.apache.jena.sparql.modify.request.UpdateDrop; +import org.apache.jena.update.UpdateRequest; import org.openmetadata.schema.api.configuration.rdf.RdfConfiguration; import org.openmetadata.service.rdf.RdfWriteMode; @@ -183,6 +190,20 @@ default String buildEntityUri(String entityType, String entityId) { */ void loadTurtleFile(java.io.InputStream turtleStream, String graphUri); + /** Replaces a complete named graph atomically, including removal of all previous blank nodes. */ + default void replaceGraph(final String graphUri, final Model model) { + if (!URI.create(graphUri).isAbsolute()) { + throw new IllegalArgumentException("A graph requires an absolute IRI"); + } + final var graph = NodeFactory.createURI(graphUri); + final var update = new UpdateRequest(new UpdateDrop(graph, true)); + update.add( + new UpdateDataInsert( + new QuadDataAcc( + model.getGraph().find().mapWith(triple -> new Quad(graph, triple)).toList()))); + executeSparqlUpdate(update.toString()); + } + /** * Get all graph URIs in the store */ diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyAiResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyAiResource.java index cae42eff2fd1..ae87b3da85e8 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyAiResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyAiResource.java @@ -63,6 +63,7 @@ import org.openmetadata.service.jobs.JobDAO; import org.openmetadata.service.llm.LLMClientHolder; import org.openmetadata.service.ontology.LlmOntologyAiCompletionGateway; +import org.openmetadata.service.ontology.NativeOntologyChanges; import org.openmetadata.service.ontology.OntologyAiAvailability; import org.openmetadata.service.ontology.OntologyAiService; import org.openmetadata.service.ontology.OntologyMemoryDerivationJobHandler; @@ -320,6 +321,10 @@ private static OntologyMemoryProposal proposal( } private static String operationLabel(final OntologyChangeOperation operation) { + final var definition = NativeOntologyChanges.definition(operation); + if (definition != null) { + return Objects.requireNonNullElse(definition.getDisplayName(), definition.getName()); + } if (operation.getTerm() != null) { return Objects.requireNonNullElse( operation.getTerm().getDisplayName(), operation.getTerm().getName()); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyAxiomMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyAxiomMapper.java index c024f146edc2..04e309f0cd2a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyAxiomMapper.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyAxiomMapper.java @@ -23,9 +23,9 @@ public final class OntologyAxiomMapper implements EntityMapper list( @Context final UriInfo uriInfo, @Context final SecurityContext securityContext, @QueryParam("fields") final String fields, - @QueryParam("glossary") final String glossary, + @QueryParam("ontologyId") final UUID ontologyId, @QueryParam("limit") @DefaultValue("50") @Min(0) @Max(1000000) final int limit, @QueryParam("before") final String before, @QueryParam("after") final String after, @QueryParam("include") @DefaultValue("non-deleted") final Include include) { - final ListFilter filter = ontologyFilter(include, glossary); + final ListFilter filter = ontologyFilter(include, ontologyId); return listInternal(uriInfo, securityContext, fields, filter, limit, before, after); } - private static ListFilter ontologyFilter(final Include include, final String glossary) { + private static ListFilter ontologyFilter(final Include include, final UUID ontologyId) { final ListFilter filter = new ListFilter(include); - if (glossary != null) { - final EntityReference reference = - Entity.getEntityReferenceByName(Entity.GLOSSARY, glossary, Include.NON_DELETED); - filter.addQueryParam("glossaryId", reference.getId().toString()); + if (ontologyId != null) { + filter.addQueryParam("ontologyId", ontologyId.toString()); } return filter; } @@ -137,6 +135,10 @@ public OntologyProfileReport validate( new OperationContext(entityType, MetadataOperation.CREATE); authorizer.authorize(securityContext, operationContext, getResourceContext()); final OntologyAxiom entity = mapper.createToEntity(request, user(securityContext)); + authorizer.authorize( + securityContext, + new OperationContext(Entity.ONTOLOGY, MetadataOperation.VIEW_ALL), + new ResourceContext<>(Entity.ONTOLOGY, entity.getOntology().getId(), null)); return repository.validate(entity); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyChangeSetMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyChangeSetMapper.java index fda1ad13a363..4a2a3e875b31 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyChangeSetMapper.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyChangeSetMapper.java @@ -16,6 +16,7 @@ import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; import java.util.List; +import java.util.Set; import org.openmetadata.schema.api.data.CreateOntologyChangeSet; import org.openmetadata.schema.entity.data.OntologyChangeSet; import org.openmetadata.schema.type.EntityReference; @@ -31,9 +32,16 @@ public final class OntologyChangeSetMapper public OntologyChangeSet createToEntity( final CreateOntologyChangeSet request, final String user) { final List glossaries = - request.getGlossaries().stream().map(fqn -> glossaryReference(request, fqn)).toList(); + (request.getGlossaries() == null ? Set.of() : request.getGlossaries()) + .stream().map(fqn -> glossaryReference(request, fqn)).toList(); return copy(new OntologyChangeSet(), request, user) .withGlossaries(glossaries) + .withOntology( + request.getOntology() == null + ? null + : Entity.getEntityReferenceByName( + Entity.ONTOLOGY, request.getOntology(), Include.NON_DELETED)) + .withBaseWorkingRevision(request.getBaseWorkingRevision()) .withState(OntologyChangeSetState.DRAFT) .withOperations(request.getOperations()) .withUndoCursor(request.getOperations() == null ? 0 : request.getOperations().size()) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyChangeSetResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyChangeSetResource.java index 57b19f90c388..92e27cc2ac45 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyChangeSetResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyChangeSetResource.java @@ -52,16 +52,18 @@ import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.OntologyChangeOperation; import org.openmetadata.schema.type.OntologyChangeSetState; import org.openmetadata.schema.type.OntologyEditLeaseToken; import org.openmetadata.schema.utils.ResultList; import org.openmetadata.service.Entity; import org.openmetadata.service.jdbi3.GlossaryTermRepository; import org.openmetadata.service.jdbi3.ListFilter; -import org.openmetadata.service.jdbi3.OntologyAxiomRepository; import org.openmetadata.service.jdbi3.OntologyChangeSetRepository; import org.openmetadata.service.limits.Limits; +import org.openmetadata.service.ontology.NativeOntologyChanges; import org.openmetadata.service.ontology.OntologyChangeApplicationService; +import org.openmetadata.service.ontology.OntologyChangeSetValidator; import org.openmetadata.service.ontology.OntologyEditLeasePolicy; import org.openmetadata.service.ontology.OntologyEditLockService; import org.openmetadata.service.resources.Collection; @@ -94,7 +96,6 @@ public OntologyChangeSetResource(final Authorizer authorizer, final Limits limit new OntologyChangeApplicationService( repository, (GlossaryTermRepository) Entity.getEntityRepository(Entity.GLOSSARY_TERM), - (OntologyAxiomRepository) Entity.getEntityRepository(Entity.ONTOLOGY_AXIOM), clock); lockService = new OntologyEditLockService(Entity.getJdbi(), clock, new OntologyEditLeasePolicy()); @@ -147,7 +148,7 @@ public Response create( @Context final SecurityContext securityContext, @Valid final CreateOntologyChangeSet request) { final OntologyChangeSet entity = mapper.createToEntity(request, user(securityContext)); - authorizeGlossaries(securityContext, entity, MetadataOperation.EDIT_GLOSSARY_TERMS); + authorizeScope(securityContext, entity, MetadataOperation.EDIT_GLOSSARY_TERMS); return create(uriInfo, securityContext, entity); } @@ -202,7 +203,7 @@ public Response submit( @PathParam("id") final UUID id, @Valid final OntologyChangeSetCommand request) { final OntologyChangeSet changeSet = scopedChangeSet(id); - authorizeGlossaries(securityContext, changeSet, MetadataOperation.EDIT_GLOSSARY_TERMS); + authorizeScope(securityContext, changeSet, MetadataOperation.EDIT_GLOSSARY_TERMS); requireLease(id, request.getLease(), securityContext); return repository .transition(uriInfo, user(securityContext), id, OntologyChangeSetState.SUBMITTED, null) @@ -218,7 +219,7 @@ public Response apply( @PathParam("id") final UUID id, @Valid final ApplyOntologyChangeSet request) { final OntologyChangeSet changeSet = scopedChangeSet(id); - authorizeGlossaries(securityContext, changeSet, MetadataOperation.EDIT_ALL); + authorizeScope(securityContext, changeSet, MetadataOperation.EDIT_ALL); requireLease(id, request.getLease(), securityContext); final PutResponse response = applicationService.apply(uriInfo, id, user(securityContext)); @@ -304,10 +305,15 @@ private void authorizeChangeSet(final SecurityContext securityContext, final UUI getResourceContextById(changeSetId)); } - private void authorizeGlossaries( + private void authorizeScope( final SecurityContext securityContext, final OntologyChangeSet changeSet, final MetadataOperation operation) { + OntologyChangeSetValidator.normalizeAndValidate(changeSet); + if (changeSet.getOntology() != null) { + authorizeNativeScope(securityContext, changeSet); + return; + } for (final EntityReference glossary : changeSet.getGlossaries()) { final Glossary planned = listOrEmpty(changeSet.getOperations()).stream() @@ -330,6 +336,41 @@ private void authorizeGlossaries( } } + private void authorizeNativeScope( + final SecurityContext context, final OntologyChangeSet changeSet) { + authorizer.authorize( + context, + new OperationContext(Entity.ONTOLOGY, MetadataOperation.EDIT_ALL), + new ResourceContext<>(Entity.ONTOLOGY, changeSet.getOntology().getId(), null)); + for (final OntologyChangeOperation change : + changeSet.getOperations().subList(0, changeSet.getUndoCursor())) { + final String type = NativeOntologyChanges.entityType(change); + if (change.getTargetId() == null) { + authorizer.authorize( + context, + new OperationContext(type, MetadataOperation.CREATE), + new CreateResourceContext<>(type, NativeOntologyChanges.definition(change))); + } else { + final MetadataOperation operation = + NativeOntologyChanges.isDelete(change) + ? MetadataOperation.DELETE + : MetadataOperation.EDIT_ALL; + authorizer.authorize( + context, + new OperationContext(type, operation), + new ResourceContext<>(type, change.getTargetId(), null)); + } + if (change.getSourceMemoryIds() != null) { + for (final UUID memoryId : change.getSourceMemoryIds()) { + authorizer.authorize( + context, + new OperationContext(Entity.CONTEXT_MEMORY, MetadataOperation.VIEW_ALL), + new ResourceContext<>(Entity.CONTEXT_MEMORY, memoryId, null)); + } + } + } + } + private void requireLease( final UUID changeSetId, final OntologyEditLeaseToken lease, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyConceptMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyConceptMapper.java new file mode 100644 index 000000000000..2b58925b5bf7 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyConceptMapper.java @@ -0,0 +1,23 @@ +package org.openmetadata.service.resources.ontology; + +import org.openmetadata.schema.api.data.CreateOntologyConcept; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.type.Include; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public final class OntologyConceptMapper + implements EntityMapper { + @Override + public OntologyConcept createToEntity(final CreateOntologyConcept request, final String user) { + return copy(new OntologyConcept(), request, user) + .withOntology( + Entity.getEntityReferenceByName( + Entity.ONTOLOGY, request.getOntology(), Include.NON_DELETED)) + .withIri(request.getIri()) + .withConceptType(request.getConceptType()) + .withSynonyms(request.getSynonyms()) + .withConceptMappings(request.getConceptMappings()) + .withProvider(request.getProvider()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyConceptResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyConceptResource.java new file mode 100644 index 000000000000..0e536171d028 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyConceptResource.java @@ -0,0 +1,213 @@ +/* + * Copyright 2026 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.resources.ontology; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.json.JsonPatch; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; +import java.util.UUID; +import org.openmetadata.schema.api.data.CreateOntologyConcept; +import org.openmetadata.schema.api.data.RestoreEntity; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.ListFilter; +import org.openmetadata.service.jdbi3.OntologyConceptRepository; +import org.openmetadata.service.limits.Limits; +import org.openmetadata.service.resources.Collection; +import org.openmetadata.service.resources.EntityResource; +import org.openmetadata.service.security.Authorizer; + +@Path("/v1/ontologyConcepts") +@Tag( + name = "Ontology Concepts", + description = "Independent ontology definitions governed through approval workflows.") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Collection(name = "ontologyConcepts", order = 3) +public class OntologyConceptResource + extends EntityResource { + public static final String COLLECTION_PATH = "/v1/ontologyConcepts/"; + private final OntologyConceptMapper mapper = new OntologyConceptMapper(); + + public OntologyConceptResource(final Authorizer authorizer, final Limits limits) { + super(Entity.ONTOLOGY_CONCEPT, authorizer, limits); + } + + public static class OntologyConceptList extends ResultList {} + + @GET + @Operation(operationId = "listOntologyConcepts", summary = "List ontology concept definitions") + public ResultList list( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @QueryParam("fields") final String fields, + @QueryParam("ontology") final String ontology, + @QueryParam("limit") @DefaultValue("50") @Min(0) @Max(1000000) final int limit, + @QueryParam("before") final String before, + @QueryParam("after") final String after, + @QueryParam("include") @DefaultValue("non-deleted") final Include include) { + final ListFilter filter = ontologyFilter(include, ontology); + return listInternal(uriInfo, securityContext, fields, filter, limit, before, after); + } + + private static ListFilter ontologyFilter(final Include include, final String ontology) { + final ListFilter filter = new ListFilter(include); + if (ontology != null) { + final EntityReference reference = + Entity.getEntityReferenceByName(Entity.ONTOLOGY, ontology, Include.NON_DELETED); + filter.addQueryParam("ontologyId", reference.getId().toString()); + } + return filter; + } + + @GET + @Path("/{id}") + @Operation(operationId = "getOntologyConceptById", summary = "Get an ontology concept") + public OntologyConcept get( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @QueryParam("fields") final String fields, + @QueryParam("include") @DefaultValue("non-deleted") final Include include) { + return getInternal(uriInfo, securityContext, id, fields, include, null); + } + + @GET + @Path("/name/{fqn}") + @Operation(operationId = "getOntologyConceptByName", summary = "Get an ontology concept by name") + public OntologyConcept getByName( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("fqn") final String fqn, + @QueryParam("fields") final String fields, + @QueryParam("include") @DefaultValue("non-deleted") final Include include) { + return getByNameInternal(uriInfo, securityContext, fqn, fields, include, null); + } + + @POST + @Operation(operationId = "createOntologyConcept", summary = "Create an ontology concept") + public Response create( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @Valid final CreateOntologyConcept request) { + final OntologyConcept entity = mapper.createToEntity(request, user(securityContext)); + return create(uriInfo, securityContext, entity); + } + + @PUT + @Operation( + operationId = "createOrUpdateOntologyConcept", + summary = "Create or update an ontology concept") + public Response createOrUpdate( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @Valid final CreateOntologyConcept request) { + final OntologyConcept entity = mapper.createToEntity(request, user(securityContext)); + return createOrUpdate(uriInfo, securityContext, entity); + } + + @PATCH + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON) + @Operation(operationId = "patchOntologyConcept", summary = "Patch an ontology concept") + public Response patch( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + final JsonPatch patch) { + return patchInternal(uriInfo, securityContext, id, patch); + } + + @GET + @Path("/{id}/versions") + @Operation( + operationId = "listOntologyConceptVersions", + summary = "List ontology concept versions") + public EntityHistory listVersions( + @Context final SecurityContext securityContext, @PathParam("id") final UUID id) { + return listVersionsInternal(securityContext, id); + } + + @GET + @Path("/{id}/versions/{version}") + @Operation(operationId = "getOntologyConceptVersion", summary = "Get an ontology concept version") + public OntologyConcept getVersion( + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @PathParam("version") final String version) { + return getVersionInternal(securityContext, id, version); + } + + @DELETE + @Path("/{id}") + @Operation(operationId = "deleteOntologyConcept", summary = "Delete an ontology concept") + public Response delete( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @QueryParam("hardDelete") @DefaultValue("false") final boolean hardDelete) { + return delete(uriInfo, securityContext, id, false, hardDelete); + } + + @DELETE + @Path("/name/{fqn}") + @Operation( + operationId = "deleteOntologyConceptByName", + summary = "Delete an ontology definition by name") + public Response deleteByName( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("fqn") final String fqn, + @QueryParam("recursive") @DefaultValue("false") final boolean recursive, + @QueryParam("hardDelete") @DefaultValue("false") final boolean hardDelete) { + return super.deleteByName(uriInfo, securityContext, fqn, recursive, hardDelete); + } + + @PUT + @Path("/restore") + @Operation(operationId = "restoreOntologyConcept", summary = "Restore an ontology concept") + public Response restore( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @Valid final RestoreEntity restore) { + return restoreEntity(uriInfo, securityContext, restore.getId()); + } + + private static String user(final SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyMapper.java new file mode 100644 index 000000000000..ece5e8b7747d --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyMapper.java @@ -0,0 +1,17 @@ +package org.openmetadata.service.resources.ontology; + +import org.openmetadata.schema.api.data.CreateOntology; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.service.mapper.EntityMapper; + +public final class OntologyMapper implements EntityMapper { + @Override + public Ontology createToEntity(final CreateOntology request, final String user) { + return copy(new Ontology(), request, user) + .withIri(request.getIri()) + .withNamespace(request.getNamespace()) + .withLayer(request.getLayer()) + .withPrefixes(request.getPrefixes()) + .withProvider(request.getProvider()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyPropertyMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyPropertyMapper.java new file mode 100644 index 000000000000..a717a1fdcdfc --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyPropertyMapper.java @@ -0,0 +1,30 @@ +package org.openmetadata.service.resources.ontology; + +import org.openmetadata.schema.api.data.CreateOntologyProperty; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.Include; +import org.openmetadata.service.Entity; +import org.openmetadata.service.mapper.EntityMapper; + +public final class OntologyPropertyMapper + implements EntityMapper { + @Override + public OntologyProperty createToEntity(final CreateOntologyProperty request, final String user) { + return copy(new OntologyProperty(), request, user) + .withOntology( + Entity.getEntityReferenceByName( + Entity.ONTOLOGY, request.getOntology(), Include.NON_DELETED)) + .withIri(request.getIri()) + .withKind(request.getKind()) + .withDomain(request.getDomain()) + .withRange(request.getRange()) + .withEnumValues(request.getEnumValues()) + .withUnit(request.getUnit()) + .withIsIdentifier(request.getIsIdentifier()) + .withInverse(request.getInverse()) + .withCharacteristics(request.getCharacteristics()) + .withPropertyChain(request.getPropertyChain()) + .withDisjointWith(request.getDisjointWith()) + .withProvider(request.getProvider()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyPropertyResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyPropertyResource.java new file mode 100644 index 000000000000..026eb097aa9c --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyPropertyResource.java @@ -0,0 +1,217 @@ +/* + * Copyright 2026 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.resources.ontology; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.json.JsonPatch; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; +import java.util.UUID; +import org.openmetadata.schema.api.data.CreateOntologyProperty; +import org.openmetadata.schema.api.data.RestoreEntity; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.ListFilter; +import org.openmetadata.service.jdbi3.OntologyPropertyRepository; +import org.openmetadata.service.limits.Limits; +import org.openmetadata.service.resources.Collection; +import org.openmetadata.service.resources.EntityResource; +import org.openmetadata.service.security.Authorizer; + +@Path("/v1/ontologyProperties") +@Tag( + name = "Ontology Properties", + description = "Independent ontology definitions governed through approval workflows.") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Collection(name = "ontologyProperties", order = 3) +public class OntologyPropertyResource + extends EntityResource { + public static final String COLLECTION_PATH = "/v1/ontologyProperties/"; + private final OntologyPropertyMapper mapper = new OntologyPropertyMapper(); + + public OntologyPropertyResource(final Authorizer authorizer, final Limits limits) { + super(Entity.ONTOLOGY_PROPERTY, authorizer, limits); + } + + public static class OntologyPropertyList extends ResultList {} + + @GET + @Operation(operationId = "listOntologyPropertys", summary = "List ontology property definitions") + public ResultList list( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @QueryParam("fields") final String fields, + @QueryParam("ontology") final String ontology, + @QueryParam("limit") @DefaultValue("50") @Min(0) @Max(1000000) final int limit, + @QueryParam("before") final String before, + @QueryParam("after") final String after, + @QueryParam("include") @DefaultValue("non-deleted") final Include include) { + final ListFilter filter = ontologyFilter(include, ontology); + return listInternal(uriInfo, securityContext, fields, filter, limit, before, after); + } + + private static ListFilter ontologyFilter(final Include include, final String ontology) { + final ListFilter filter = new ListFilter(include); + if (ontology != null) { + final EntityReference reference = + Entity.getEntityReferenceByName(Entity.ONTOLOGY, ontology, Include.NON_DELETED); + filter.addQueryParam("ontologyId", reference.getId().toString()); + } + return filter; + } + + @GET + @Path("/{id}") + @Operation(operationId = "getOntologyPropertyById", summary = "Get an ontology property") + public OntologyProperty get( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @QueryParam("fields") final String fields, + @QueryParam("include") @DefaultValue("non-deleted") final Include include) { + return getInternal(uriInfo, securityContext, id, fields, include, null); + } + + @GET + @Path("/name/{fqn}") + @Operation( + operationId = "getOntologyPropertyByName", + summary = "Get an ontology property by name") + public OntologyProperty getByName( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("fqn") final String fqn, + @QueryParam("fields") final String fields, + @QueryParam("include") @DefaultValue("non-deleted") final Include include) { + return getByNameInternal(uriInfo, securityContext, fqn, fields, include, null); + } + + @POST + @Operation(operationId = "createOntologyProperty", summary = "Create an ontology property") + public Response create( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @Valid final CreateOntologyProperty request) { + final OntologyProperty entity = mapper.createToEntity(request, user(securityContext)); + return create(uriInfo, securityContext, entity); + } + + @PUT + @Operation( + operationId = "createOrUpdateOntologyProperty", + summary = "Create or update an ontology property") + public Response createOrUpdate( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @Valid final CreateOntologyProperty request) { + final OntologyProperty entity = mapper.createToEntity(request, user(securityContext)); + return createOrUpdate(uriInfo, securityContext, entity); + } + + @PATCH + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON) + @Operation(operationId = "patchOntologyProperty", summary = "Patch an ontology property") + public Response patch( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + final JsonPatch patch) { + return patchInternal(uriInfo, securityContext, id, patch); + } + + @GET + @Path("/{id}/versions") + @Operation( + operationId = "listOntologyPropertyVersions", + summary = "List ontology property versions") + public EntityHistory listVersions( + @Context final SecurityContext securityContext, @PathParam("id") final UUID id) { + return listVersionsInternal(securityContext, id); + } + + @GET + @Path("/{id}/versions/{version}") + @Operation( + operationId = "getOntologyPropertyVersion", + summary = "Get an ontology property version") + public OntologyProperty getVersion( + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @PathParam("version") final String version) { + return getVersionInternal(securityContext, id, version); + } + + @DELETE + @Path("/{id}") + @Operation(operationId = "deleteOntologyProperty", summary = "Delete an ontology property") + public Response delete( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @QueryParam("hardDelete") @DefaultValue("false") final boolean hardDelete) { + return delete(uriInfo, securityContext, id, false, hardDelete); + } + + @DELETE + @Path("/name/{fqn}") + @Operation( + operationId = "deleteOntologyPropertyByName", + summary = "Delete an ontology definition by name") + public Response deleteByName( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("fqn") final String fqn, + @QueryParam("recursive") @DefaultValue("false") final boolean recursive, + @QueryParam("hardDelete") @DefaultValue("false") final boolean hardDelete) { + return super.deleteByName(uriInfo, securityContext, fqn, recursive, hardDelete); + } + + @PUT + @Path("/restore") + @Operation(operationId = "restoreOntologyProperty", summary = "Restore an ontology property") + public Response restore( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @Valid final RestoreEntity restore) { + return restoreEntity(uriInfo, securityContext, restore.getId()); + } + + private static String user(final SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyReleaseResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyReleaseResource.java new file mode 100644 index 000000000000..0cfa16741471 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyReleaseResource.java @@ -0,0 +1,222 @@ +package org.openmetadata.service.resources.ontology; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; +import java.util.List; +import java.util.UUID; +import org.openmetadata.schema.api.data.OntologyReleaseDiff; +import org.openmetadata.schema.api.data.OntologyReleaseStructureReport; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.OntologyReleaseEvent; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.ListFilter; +import org.openmetadata.service.jdbi3.OntologyReleaseRepository; +import org.openmetadata.service.limits.Limits; +import org.openmetadata.service.ontology.OntologyReleaseComparison; +import org.openmetadata.service.ontology.OntologyReleaseReader; +import org.openmetadata.service.ontology.OntologyReleaseStructure; +import org.openmetadata.service.rdf.OntologyReleaseExporter; +import org.openmetadata.service.rdf.RdfSerializationFormat; +import org.openmetadata.service.resources.Collection; +import org.openmetadata.service.resources.EntityResource; +import org.openmetadata.service.security.Authorizer; + +@Path("/v1/ontologyReleases") +@Tag( + name = "Ontology Releases", + description = "Immutable captured ontology definitions and their workflow status.") +@Produces(MediaType.APPLICATION_JSON) +@Collection(name = "ontologyReleases", order = 4) +public final class OntologyReleaseResource + extends EntityResource { + private final OntologyReleaseExporter exporter; + private final OntologyReleaseStructure structure; + private final OntologyReleaseComparison comparison; + + public OntologyReleaseResource(final Authorizer authorizer, final Limits limits) { + super(Entity.ONTOLOGY_RELEASE, authorizer, limits); + exporter = new OntologyReleaseExporter(repository); + structure = new OntologyReleaseStructure(repository); + comparison = new OntologyReleaseComparison(repository); + } + + public static class OntologyReleaseList extends ResultList {} + + @GET + @Operation(operationId = "listOntologyReleases", summary = "List sealed ontology releases") + public ResultList list( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @QueryParam("ontologyId") final UUID ontologyId, + @QueryParam("limit") @DefaultValue("50") @Min(1) @Max(1000) final int limit, + @QueryParam("after") final String after) { + final ListFilter filter = new ListFilter(Include.NON_DELETED); + if (ontologyId != null) { + filter.addQueryParam("ontologyId", ontologyId.toString()); + } + return listInternal(uriInfo, securityContext, "owners,reviewers", filter, limit, null, after); + } + + @GET + @Path("/{id}") + @Operation(operationId = "getOntologyRelease", summary = "Get a sealed ontology release") + public OntologyRelease get( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id) { + return getInternal(uriInfo, securityContext, id, "owners,reviewers", Include.NON_DELETED, null); + } + + @GET + @Path("/{id}/versions/{version}") + @Operation( + operationId = "getOntologyReleaseVersion", + summary = "Read the release header version submitted to an approval workflow") + public OntologyRelease getVersion( + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @PathParam("version") final String version) { + return getVersionInternal(securityContext, id, version); + } + + @GET + @Path("/{id}/diff") + @Operation( + operationId = "compareOntologyReleases", + summary = "Compare definitions from two sealed releases", + description = + "Omitting the base compares with the captured base release, or an empty ontology for the first release. Both closures must be readable and belong to the same ontology. Workflow-only changes are excluded; this does not compute logical equivalence or inferred impact.") + public OntologyReleaseDiff diff( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @QueryParam("baseReleaseId") final UUID baseReleaseId, + @QueryParam("limit") @DefaultValue("50") @Min(1) @Max(1000) final int limit, + @QueryParam("after") final String after) { + final OntologyRelease release = get(uriInfo, securityContext, id); + final UUID effectiveBase = baseReleaseId == null ? release.getBaseReleaseId() : baseReleaseId; + final OntologyRelease base = + effectiveBase == null ? null : get(uriInfo, securityContext, effectiveBase); + return comparison.compare( + release, + base, + limit, + after, + definition -> OntologyResourceAccess.requireView(authorizer, securityContext, definition)); + } + + @GET + @Path("/{id}/structure") + @Operation( + operationId = "getOntologyReleaseStructure", + summary = "Check property declarations and axiom structure in a sealed release", + description = + "Returns structural diagnostics over frozen SQL members. It does not run the complete OWL 2 DL profile or logical checks, approve a release, or change publication state.") + public OntologyReleaseStructureReport structure( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id) { + final OntologyRelease release = get(uriInfo, securityContext, id); + return structure.report( + release, + definition -> OntologyResourceAccess.requireView(authorizer, securityContext, definition)); + } + + @GET + @Path("/{id}/export") + @Produces({"text/turtle", "application/rdf+xml", "application/n-triples", "application/ld+json"}) + @Operation( + operationId = "exportOntologyRelease", + summary = "Export the asserted content of a sealed ontology release", + description = + "Exports the captured definitions, independently of working edits and RDF service availability. Export does not approve or activate a candidate.") + public Response export( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @QueryParam("format") @DefaultValue("turtle") final String requestedFormat) { + final OntologyRelease release = get(uriInfo, securityContext, id); + final RdfSerializationFormat format = RdfSerializationFormat.parse(requestedFormat); + final String content = + exporter.export( + release, + format, + definition -> + OntologyResourceAccess.requireView(authorizer, securityContext, definition)); + return Response.ok(content, format.mediaType()) + .header( + "Content-Disposition", + "attachment; filename=\"ontology-release-" + id + "." + format.extension() + "\"") + .build(); + } + + @GET + @Path("/{id}/events") + @Operation( + operationId = "listOntologyReleaseEvents", + summary = "Read durable release capture and workflow decisions") + public ResultList listEvents( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @QueryParam("limit") @DefaultValue("50") @Min(1) @Max(1000) final int limit, + @QueryParam("after") final String after) { + get(uriInfo, securityContext, id); + return repository.listEvents(id, limit, after); + } + + @GET + @Path("/{id}/members/{kind}/{memberId}") + @Operation(operationId = "getOntologyReleaseMember", summary = "Read an immutable release member") + public OntologyReleaseMember getMember( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @PathParam("kind") final MemberKind kind, + @PathParam("memberId") final UUID memberId) { + get(uriInfo, securityContext, id); + final OntologyReleaseMember member = repository.getMember(id, kind, memberId); + OntologyResourceAccess.requireView( + authorizer, securityContext, List.of(OntologyReleaseReader.definition(member))); + return member; + } + + @GET + @Path("/{id}/members/{kind}") + @Operation( + operationId = "listOntologyReleaseMembers", + summary = "Page through one release's immutable members") + public ResultList listMembers( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @PathParam("kind") final MemberKind kind, + @QueryParam("limit") @DefaultValue("50") @Min(1) @Max(1000) final int limit, + @QueryParam("after") final String after) { + get(uriInfo, securityContext, id); + final ResultList members = + repository.listMembers(id, kind, limit, after); + OntologyResourceAccess.requireView( + authorizer, + securityContext, + members.getData().stream().map(OntologyReleaseReader::definition).toList()); + return members; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyResource.java new file mode 100644 index 000000000000..6468e94a7c63 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyResource.java @@ -0,0 +1,390 @@ +/* + * Copyright 2026 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.resources.ontology; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.json.JsonPatch; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.ServiceUnavailableException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; +import java.net.URI; +import java.time.Clock; +import java.util.Map; +import java.util.UUID; +import org.openmetadata.schema.api.data.ActivateOntologyRelease; +import org.openmetadata.schema.api.data.CaptureOntologyRelease; +import org.openmetadata.schema.api.data.CreateOntology; +import org.openmetadata.schema.api.data.RestoreEntity; +import org.openmetadata.schema.api.rdf.SparqlQuery; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.OntologyReference; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.type.OntologyState; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.ListFilter; +import org.openmetadata.service.jdbi3.OntologyAxiomRepository; +import org.openmetadata.service.jdbi3.OntologyConceptRepository; +import org.openmetadata.service.jdbi3.OntologyPropertyRepository; +import org.openmetadata.service.jdbi3.OntologyReleaseRepository; +import org.openmetadata.service.jdbi3.OntologyRepository; +import org.openmetadata.service.limits.Limits; +import org.openmetadata.service.ontology.OntologyReleaseActivation; +import org.openmetadata.service.ontology.OntologyReleaseCapture; +import org.openmetadata.service.ontology.OntologyReleaseQuery; +import org.openmetadata.service.rdf.RdfUpdater; +import org.openmetadata.service.rdf.SparqlQueryExecutionGuard; +import org.openmetadata.service.rdf.federation.SparqlFederationGuard; +import org.openmetadata.service.resources.Collection; +import org.openmetadata.service.resources.EntityResource; +import org.openmetadata.service.security.Authorizer; +import org.openmetadata.service.security.policyevaluator.OperationContext; +import org.openmetadata.service.security.policyevaluator.ResourceContext; + +@Path("/v1/ontologies") +@Tag( + name = "Ontologies", + description = "Independent ontology definitions governed through approval workflows.") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Collection(name = "ontologies", order = 3) +public class OntologyResource extends EntityResource { + public static final String COLLECTION_PATH = "/v1/ontologies/"; + private final OntologyMapper mapper = new OntologyMapper(); + private final OntologyReleaseCapture releaseCapture; + private final OntologyReleaseActivation releaseActivation; + + public OntologyResource(final Authorizer authorizer, final Limits limits) { + super(Entity.ONTOLOGY, authorizer, limits); + releaseActivation = + new OntologyReleaseActivation( + repository, + Clock.systemUTC(), + id -> RdfUpdater.enqueueOntologyGraph(repository.getDaoCollection(), id)); + releaseCapture = + new OntologyReleaseCapture( + (OntologyReleaseRepository) Entity.getEntityRepository(Entity.ONTOLOGY_RELEASE), + repository, + Map.of( + MemberKind.CONCEPT, + (OntologyConceptRepository) Entity.getEntityRepository(Entity.ONTOLOGY_CONCEPT), + MemberKind.PROPERTY, + (OntologyPropertyRepository) Entity.getEntityRepository(Entity.ONTOLOGY_PROPERTY), + MemberKind.AXIOM, + (OntologyAxiomRepository) Entity.getEntityRepository(Entity.ONTOLOGY_AXIOM)), + Clock.systemUTC()); + } + + public static class OntologyList extends ResultList {} + + @POST + @Path("/{id}/sparql") + @Consumes(MediaType.APPLICATION_JSON) + @Produces({ + "application/sparql-results+json", + "application/sparql-results+xml", + "text/csv", + "text/tab-separated-values", + "text/turtle", + "application/rdf+xml", + "application/n-triples", + "application/ld+json" + }) + @Operation( + operationId = "queryActiveOntologyRelease", + summary = "Query the active ontology release", + description = + "Runs read-only SPARQL over the active release's verified SQL projection. Requires view access to every captured member and cannot access other graphs or external services. Working edits are excluded.") + public Response query( + @Context final UriInfo uriInfo, + @Context final SecurityContext context, + @PathParam("id") final UUID id, + @Valid final SparqlQuery request) { + getInternal(uriInfo, context, id, "", Include.NON_DELETED, null); + final var service = + new OntologyReleaseQuery( + repository, + (OntologyReleaseRepository) Entity.getEntityRepository(Entity.ONTOLOGY_RELEASE)); + try { + final var result = + SparqlQueryExecutionGuard.shared() + .execute( + user(context), + () -> + service.query( + id, + request, + definition -> + OntologyResourceAccess.requireView(authorizer, context, definition))); + return Response.ok(result.body(), result.mediaType()).build(); + } catch (SparqlFederationGuard.FederationDisallowedException exception) { + throw new ForbiddenException(exception.getMessage(), exception); + } catch (SparqlQueryExecutionGuard.QueryCapacityException exception) { + throw new ClientErrorException(exception.getMessage(), Response.Status.TOO_MANY_REQUESTS); + } catch (SparqlQueryExecutionGuard.QueryTimeoutException exception) { + throw new ServiceUnavailableException( + Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(exception.getMessage()) + .build(), + exception); + } + } + + @PUT + @Path("/{id}/activation") + @Operation( + operationId = "activateOntologyRelease", + summary = "Activate an approved ontology release or roll back to an older release") + public OntologyState activateRelease( + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @Valid final ActivateOntologyRelease request) { + authorizer.authorize( + securityContext, + new OperationContext(Entity.ONTOLOGY, MetadataOperation.EDIT_ALL), + getResourceContextById(id)); + return releaseActivation.activate( + id, + request, + new OntologyReleaseActivation.Access( + user(securityContext), + definition -> + OntologyResourceAccess.requireView(authorizer, securityContext, definition))); + } + + @GET + @Path("/{id}/state") + @Operation( + operationId = "getOntologyState", + summary = "Get ontology working and activation revisions") + public OntologyState getState( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id) { + getInternal(uriInfo, securityContext, id, "", Include.NON_DELETED, null); + return repository.getState(id); + } + + @GET + @Path("/{id}/references") + @Operation( + operationId = "listOntologyReferences", + summary = "Find working definitions that reference an exact IRI in this ontology") + public ResultList listReferences( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @QueryParam("iri") final URI iri, + @QueryParam("limit") @DefaultValue("50") @Min(1) @Max(1000) final int limit, + @QueryParam("after") final String after) { + getInternal(uriInfo, securityContext, id, "", Include.NON_DELETED, null); + final ResultList references = + repository.listReferences(id, iri, limit, after); + for (final OntologyReference reference : references.getData()) { + final var source = reference.getSource(); + final ResourceContext context = + new ResourceContext<>(source.getType(), source.getId(), null); + authorizer.authorize( + securityContext, + new OperationContext(source.getType(), MetadataOperation.VIEW_ALL), + context); + reference.setSource(context.getEntity().getEntityReference()); + } + return references; + } + + @POST + @Path("/{id}/releases") + @Operation( + operationId = "captureOntologyRelease", + summary = "Capture immutable ontology release content") + public Response captureRelease( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @Valid final CaptureOntologyRelease request) { + authorizer.authorize( + securityContext, + new OperationContext(Entity.ONTOLOGY, MetadataOperation.EDIT_ALL), + getResourceContextById(id)); + final OntologyRelease release = + releaseCapture.capture( + id, + request, + new OntologyReleaseCapture.Access( + user(securityContext), + definition -> + OntologyResourceAccess.requireView(authorizer, securityContext, definition))); + final URI location = + uriInfo + .getBaseUriBuilder() + .path("v1/ontologyReleases") + .path(release.getId().toString()) + .build(); + release.setHref(location); + return Response.created(location).entity(release).build(); + } + + @GET + @Operation(operationId = "listOntologies", summary = "List ontology definitions") + public ResultList list( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @QueryParam("fields") final String fields, + @QueryParam("limit") @DefaultValue("50") @Min(0) @Max(1000000) final int limit, + @QueryParam("before") final String before, + @QueryParam("after") final String after, + @QueryParam("include") @DefaultValue("non-deleted") final Include include) { + final ListFilter filter = new ListFilter(include); + return listInternal(uriInfo, securityContext, fields, filter, limit, before, after); + } + + @GET + @Path("/{id}") + @Operation(operationId = "getOntologyById", summary = "Get an ontology") + public Ontology get( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @QueryParam("fields") final String fields, + @QueryParam("include") @DefaultValue("non-deleted") final Include include) { + return getInternal(uriInfo, securityContext, id, fields, include, null); + } + + @GET + @Path("/name/{fqn}") + @Operation(operationId = "getOntologyByName", summary = "Get an ontology by name") + public Ontology getByName( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("fqn") final String fqn, + @QueryParam("fields") final String fields, + @QueryParam("include") @DefaultValue("non-deleted") final Include include) { + return getByNameInternal(uriInfo, securityContext, fqn, fields, include, null); + } + + @POST + @Operation(operationId = "createOntology", summary = "Create an ontology") + public Response create( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @Valid final CreateOntology request) { + final Ontology entity = mapper.createToEntity(request, user(securityContext)); + return create(uriInfo, securityContext, entity); + } + + @PUT + @Operation(operationId = "createOrUpdateOntology", summary = "Create or update an ontology") + public Response createOrUpdate( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @Valid final CreateOntology request) { + final Ontology entity = mapper.createToEntity(request, user(securityContext)); + return createOrUpdate(uriInfo, securityContext, entity); + } + + @PATCH + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON) + @Operation(operationId = "patchOntology", summary = "Patch an ontology") + public Response patch( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + final JsonPatch patch) { + return patchInternal(uriInfo, securityContext, id, patch); + } + + @GET + @Path("/{id}/versions") + @Operation(operationId = "listOntologyVersions", summary = "List ontology versions") + public EntityHistory listVersions( + @Context final SecurityContext securityContext, @PathParam("id") final UUID id) { + return listVersionsInternal(securityContext, id); + } + + @GET + @Path("/{id}/versions/{version}") + @Operation(operationId = "getOntologyVersion", summary = "Get an ontology version") + public Ontology getVersion( + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @PathParam("version") final String version) { + return getVersionInternal(securityContext, id, version); + } + + @DELETE + @Path("/{id}") + @Operation(operationId = "deleteOntology", summary = "Delete an ontology") + public Response delete( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("id") final UUID id, + @QueryParam("recursive") @DefaultValue("false") final boolean recursive, + @QueryParam("hardDelete") @DefaultValue("false") final boolean hardDelete) { + return super.delete(uriInfo, securityContext, id, recursive, hardDelete); + } + + @DELETE + @Path("/name/{fqn}") + @Operation( + operationId = "deleteOntologyByName", + summary = "Delete an ontology definition by name") + public Response deleteByName( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @PathParam("fqn") final String fqn, + @QueryParam("recursive") @DefaultValue("false") final boolean recursive, + @QueryParam("hardDelete") @DefaultValue("false") final boolean hardDelete) { + return super.deleteByName(uriInfo, securityContext, fqn, recursive, hardDelete); + } + + @PUT + @Path("/restore") + @Operation(operationId = "restoreOntology", summary = "Restore an ontology") + public Response restore( + @Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @Valid final RestoreEntity restore) { + return restoreEntity(uriInfo, securityContext, restore.getId()); + } + + private static String user(final SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyResourceAccess.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyResourceAccess.java new file mode 100644 index 000000000000..9e844014e629 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyResourceAccess.java @@ -0,0 +1,46 @@ +package org.openmetadata.service.resources.ontology; + +import jakarta.ws.rs.core.SecurityContext; +import java.util.List; +import java.util.stream.Collectors; +import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.EntityRepository; +import org.openmetadata.service.security.Authorizer; +import org.openmetadata.service.security.policyevaluator.OperationContext; +import org.openmetadata.service.security.policyevaluator.ResourceContext; + +/** Applies current policies to the definition snapshot actually being read. */ +final class OntologyResourceAccess { + private OntologyResourceAccess() {} + + static void requireView( + final Authorizer authorizer, + final SecurityContext context, + final List definitions) { + definitions.stream() + .collect(Collectors.groupingBy(definition -> definition.getEntityReference().getType())) + .forEach( + (type, batch) -> + requireView(authorizer, context, batch, Entity.getEntityRepository(type))); + } + + private static > void requireView( + final Authorizer authorizer, + final SecurityContext context, + final List definitions, + final EntityRepository repository) { + final String type = repository.getEntityType(); + final List batch = definitions.stream().map(repository.getEntityClass()::cast).toList(); + final String fields = Entity.entityHasField(type, "reviewers") ? "owners,reviewers" : "owners"; + repository.setFieldsInBulk(repository.getFields(fields), batch); + batch.forEach( + definition -> + authorizer.authorize( + context, + new OperationContext(type, MetadataOperation.VIEW_ALL), + new ResourceContext<>(type, definition, repository))); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tasks/TaskResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tasks/TaskResource.java index 6eb4b280a837..a8d9c638812d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/tasks/TaskResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/tasks/TaskResource.java @@ -60,6 +60,7 @@ import org.openmetadata.schema.api.tasks.CreateTaskComment; import org.openmetadata.schema.api.tasks.ResolveTask; import org.openmetadata.schema.api.tasks.TaskCount; +import org.openmetadata.schema.entity.data.OntologyRelease; import org.openmetadata.schema.entity.tasks.Task; import org.openmetadata.schema.entity.teams.User; import org.openmetadata.schema.type.BulkTaskOperationParams; @@ -86,6 +87,7 @@ import org.openmetadata.service.context.review.MemoryConflictResolution; import org.openmetadata.service.exception.BadRequestException; import org.openmetadata.service.governance.approval.ChangeRequestReview; +import org.openmetadata.service.governance.workflows.OntologyApprovalWorkflow; import org.openmetadata.service.jdbi3.CollectionDAO; import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.jdbi3.ListFilter; @@ -1043,6 +1045,17 @@ private void authorizeCreateTaskOnAboutEntity(SecurityContext securityContext, T OperationContext operationContext = new OperationContext(aboutRef.getType(), MetadataOperation.CREATE_TASK); authorizer.authorize(securityContext, operationContext, resourceContext); + if (OntologyApprovalWorkflow.isReview(task)) { + EntityReference target = aboutRef; + if (Entity.ONTOLOGY_RELEASE.equals(aboutRef.getType())) { + OntologyRelease release = Entity.getEntity(aboutRef, "", Include.NON_DELETED); + target = release.getOntology(); + } + authorizer.authorize( + securityContext, + new OperationContext(target.getType(), MetadataOperation.EDIT_ALL), + new ResourceContext<>(target.getType(), target.getId(), null)); + } } private void authorizeViewOnAboutEntity( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowHandler.java index ffb39e88a1fb..b734294a52bb 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowHandler.java @@ -57,8 +57,12 @@ import org.openmetadata.service.governance.approval.ApprovalDecisionService.ReviewOutcome; import org.openmetadata.service.governance.approval.ChangeRequestReview; import org.openmetadata.service.governance.approval.ChangeRequestTasks; +import org.openmetadata.service.governance.workflows.OntologyApprovalWorkflow; +import org.openmetadata.service.governance.workflows.OntologyReviewReconciler; +import org.openmetadata.service.governance.workflows.OntologyWorkflowDecision; import org.openmetadata.service.governance.workflows.WorkflowEventConsumer; import org.openmetadata.service.governance.workflows.WorkflowHandler; +import org.openmetadata.service.governance.workflows.WorkflowHandler.TaskResolutionOutcome; import org.openmetadata.service.governance.workflows.metadata.MetadataCollectionService; import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.jdbi3.TaskRepository; @@ -202,6 +206,13 @@ public Task resolveTask( // During migration cutover, legacy workflow tasks can be converted to Task entities before // workflowInstanceId is backfilled. Runtime-task presence is the source of truth in that case. boolean isWorkflowManaged = isWorkflowManaged(task); + if (OntologyApprovalWorkflow.isReview(task)) { + if (!isWorkflowManaged || resolvedPayload != null || newValue != null) { + throw TaskStateConflictException.of( + "Ontology review must complete its workflow task without changing the reviewed content"); + } + OntologyApprovalWorkflow.validate(task, user); + } if (isWorkflowManaged) { return resolveWorkflowTask( @@ -248,7 +259,8 @@ private Task resolveWorkflowTask( WorkflowHandler workflowHandler = WorkflowHandler.getInstance(); TaskRepository taskRepository = (TaskRepository) Entity.getEntityRepository(Entity.TASK); List payloadAssignees = extractAssigneesFromPayload(resolvedPayload); - boolean requiresRuntimeTaskReadiness = isMetricApprovalTask(task); + boolean ontologyReview = OntologyApprovalWorkflow.isReview(task); + boolean requiresRuntimeTaskReadiness = isMetricApprovalTask(task) || ontologyReview; if (requiresRuntimeTaskReadiness && TaskRepository.isTerminalStatus(task.getStatus())) { throw new IllegalStateException( @@ -260,6 +272,9 @@ private Task resolveWorkflowTask( "Flowable runtime task for workflow-managed task '%s' is unavailable; retry the resolution", taskId)); } + if (ontologyReview) { + OntologyApprovalWorkflow.validateRuntime(task); + } if (payloadAssignees != null && !payloadAssignees.isEmpty()) { task = persistWorkflowAssignees(taskRepository, task, payloadAssignees, user); @@ -298,17 +313,45 @@ private Task resolveWorkflowTask( // transition result and mis-evaluate the outgoing gateway, so treat it as a failed resolve. Map namespacedVariables = workflowHandler.transformToNodeVariables(taskId, variables); + if (ontologyReview && namespacedVariables != null) { + TaskResolutionType decision = + resolutionType != null + ? resolutionType + : TaskWorkflowLifecycleResolver.isApproveTransition(selectedTransition) + ? TaskResolutionType.Approved + : TaskResolutionType.Rejected; + OntologyApprovalWorkflow.recordDecision( + task, + new OntologyWorkflowDecision( + taskId, + task.getOntologyReview(), + decision, + user, + List.of(user), + selectedTransition, + comment), + namespacedVariables); + } + TaskResolutionOutcome ontologyOutcome = + ontologyReview && namespacedVariables != null + ? workflowHandler.resolveTaskOutcome(taskId, namespacedVariables) + : null; boolean workflowSuccess = - namespacedVariables != null && workflowHandler.resolveTask(taskId, namespacedVariables); + ontologyReview + ? ontologyOutcome != null + && ontologyOutcome != TaskResolutionOutcome.RESOLUTION_FAILED + && ontologyOutcome != TaskResolutionOutcome.PROCESS_FAILED + : namespacedVariables != null + && workflowHandler.resolveTask(taskId, namespacedVariables); if (!workflowSuccess) { if (!workflowHandler.hasActiveRuntimeTask(taskId)) { - // Workflow-managed Metric tasks must not bypass their approval workflow when the runtime + // Governed approval tasks must not bypass their approval workflow when the runtime // task disappears between the readiness check and resolution. if (requiresRuntimeTaskReadiness) { throw TaskStateConflictException.of( String.format( - "Flowable runtime task for workflow-managed Metric task '%s' disappeared while resolving transition '%s'; the task was not finalized", + "Flowable runtime task for workflow-managed approval task '%s' disappeared while resolving transition '%s'; the task was not finalized", taskId, transitionId != null ? transitionId : defaultWorkflowResult(resolutionType))); } @@ -356,7 +399,9 @@ private Task resolveWorkflowTask( } // Check if multi-approval task is still waiting for more votes - if (workflowHandler.isAwaitingAdditionalVotes(taskId)) { + if (ontologyReview + ? ontologyOutcome == TaskResolutionOutcome.WAITING_FOR_VOTES + : workflowHandler.isAwaitingAdditionalVotes(taskId)) { LOG.info("[TaskWorkflowHandler] Task '{}' still open, waiting for more approvals", taskId); persistEditedWorkflowPayload(task, resolvedPayload, newValue, user); updateTaskVotes(task, user, isPositiveResolution(resolutionType)); @@ -364,6 +409,10 @@ private Task resolveWorkflowTask( } // Task threshold met, apply resolution + if (ontologyReview) { + new OntologyReviewReconciler(Entity.getCollectionDAO()).reconcile(taskId); + return refreshTask(taskId); + } return applyTaskResolution( task, resolutionType, selectedTransition, newValue, resolvedPayload, comment, user); } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/exception/CatalogGenericExceptionMapperTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/exception/CatalogGenericExceptionMapperTest.java index 6180b02c64a8..703e0fdda628 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/exception/CatalogGenericExceptionMapperTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/exception/CatalogGenericExceptionMapperTest.java @@ -23,10 +23,17 @@ import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; import java.lang.reflect.Field; +import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.openmetadata.schema.api.rdf.AgentSparqlError; import org.openmetadata.schema.api.rdf.AgentSparqlErrorCode; +import org.openmetadata.schema.type.OntologyProfileViolation; +import org.openmetadata.schema.type.OntologyProfileViolationCode; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.type.OntologyReleaseStructureIssue; +import org.openmetadata.schema.utils.JsonUtils; import org.openmetadata.service.security.AuthenticationException; import org.openmetadata.service.security.ImpersonationDeniedException; @@ -35,6 +42,29 @@ * failures raised by request filters; every other path keeps the existing error shape. */ class CatalogGenericExceptionMapperTest { + @Test + void ontologyValidationRetainsTypedMemberDetailsInTheHttpResponse() { + final UUID member = UUID.randomUUID(); + final var issue = + new OntologyReleaseStructureIssue() + .withMemberId(member) + .withMemberKind(MemberKind.AXIOM) + .withViolation( + new OntologyProfileViolation() + .withCode(OntologyProfileViolationCode.UNDECLARED_CLASS) + .withPath("/subjectIri") + .withMessage("Declare the subject class")); + final var response = + new CatalogGenericExceptionMapper() + .toResponse(new OntologyValidationException(1, List.of(issue))); + final var json = JsonUtils.valueToTree(response.getEntity()); + assertEquals(400, response.getStatus()); + assertEquals(400, json.path("code").asInt()); + assertEquals(1, json.path("violationCount").asInt()); + assertEquals(member.toString(), json.at("/violations/0/memberId").asText()); + assertEquals("/subjectIri", json.at("/violations/0/violation/path").asText()); + } + @Test void agentPathDelegatesToTheStableEnvelope() { CatalogGenericExceptionMapper mapper = mapperForAgentPath(); diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/governance/workflows/OntologyApprovalWorkflowTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/governance/workflows/OntologyApprovalWorkflowTest.java new file mode 100644 index 000000000000..e944dfe66fc2 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/governance/workflows/OntologyApprovalWorkflowTest.java @@ -0,0 +1,106 @@ +package org.openmetadata.service.governance.workflows; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.OntologyReviewTarget; +import org.openmetadata.schema.type.TaskEntityStatus; +import org.openmetadata.schema.type.TaskEntityType; +import org.openmetadata.schema.type.TaskResolution; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; + +class OntologyApprovalWorkflowTest { + @Test + void ontologyReviewCannotBeRetargetedOrConvertedToAnotherTaskType() { + Task original = reviewTask(); + assertRejected(original, task -> task.getOntologyReview().setContentDigest("b".repeat(64))); + assertRejected(original, task -> task.setOntologyReview(null)); + assertRejected(original, task -> task.setWorkflowDefinitionId(UUID.randomUUID())); + assertRejected(original, task -> task.getAbout().setId(UUID.randomUUID())); + assertRejected(original, task -> task.setType(TaskEntityType.GlossaryApproval)); + } + + @Test + void hydratedReferencePresentationDoesNotRetargetTheTask() { + Task original = reviewTask(); + Task updated = JsonUtils.deepCopy(original, Task.class); + updated.getAbout().setDisplayName("Sales model"); + + assertDoesNotThrow(() -> OntologyApprovalWorkflow.requireUnchangedTarget(original, updated)); + } + + @Test + void finishedReviewsRequireANewTaskInsteadOfReusingVotes() { + for (TaskEntityStatus status : + new TaskEntityStatus[] { + TaskEntityStatus.Approved, TaskEntityStatus.Rejected, TaskEntityStatus.Cancelled + }) { + Task original = + reviewTask() + .withStatus(status) + .withResolution(new TaskResolution().withType(TaskResolutionType.Completed)); + assertRejected(original, task -> task.setStatus(TaskEntityStatus.Open)); + assertRejected(original, task -> task.setResolution(null)); + assertRejected(original, task -> task.setStatus(TaskEntityStatus.InProgress)); + assertDoesNotThrow(() -> OntologyApprovalWorkflow.requireUnchangedTarget(original, original)); + } + } + + @Test + void cancellationCannotBeOverwrittenByALateDecision() { + Task cancelled = reviewTask().withStatus(TaskEntityStatus.Cancelled); + assertRejected(cancelled, task -> task.setStatus(TaskEntityStatus.Approved)); + assertRejected(cancelled, task -> task.setStatus(TaskEntityStatus.Rejected)); + } + + @Test + void reviewFilerCannotBeChangedToPermitSelfApproval() { + Task original = + reviewTask() + .withCreatedBy(new EntityReference().withId(UUID.randomUUID()).withType(Entity.USER)); + assertRejected(original, task -> task.getCreatedBy().setId(UUID.randomUUID())); + assertRejected(original, task -> task.setCreatedBy(null)); + } + + @Test + void glossaryApprovalKeepsItsExistingLifecycle() { + Task glossary = + new Task() + .withType(TaskEntityType.GlossaryApproval) + .withAbout(new EntityReference().withType(Entity.GLOSSARY_TERM)); + Task updated = + JsonUtils.deepCopy(glossary, Task.class).withWorkflowDefinitionId(UUID.randomUUID()); + + assertFalse(OntologyApprovalWorkflow.isReview(glossary)); + assertDoesNotThrow(() -> OntologyApprovalWorkflow.requireUnchangedTarget(glossary, updated)); + assertTrue(OntologyApprovalWorkflow.isReview(reviewTask())); + } + + private static void assertRejected(Task original, Consumer edit) { + Task updated = JsonUtils.deepCopy(original, Task.class); + edit.accept(updated); + assertThrows( + BadRequestException.class, + () -> OntologyApprovalWorkflow.requireUnchangedTarget(original, updated)); + } + + private static Task reviewTask() { + UUID entityId = UUID.randomUUID(); + return new Task() + .withType(TaskEntityType.RequestApproval) + .withAbout(new EntityReference().withId(entityId).withType(Entity.ONTOLOGY)) + .withWorkflowDefinitionId(UUID.randomUUID()) + .withOntologyReview( + new OntologyReviewTarget().withEntityId(entityId).withContentDigest("a".repeat(64))); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/governance/workflows/OntologyWorkflowDecisionTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/governance/workflows/OntologyWorkflowDecisionTest.java new file mode 100644 index 000000000000..2a2ccc4b4157 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/governance/workflows/OntologyWorkflowDecisionTest.java @@ -0,0 +1,84 @@ +package org.openmetadata.service.governance.workflows; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.OntologyReviewTarget; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.exception.BadRequestException; + +class OntologyWorkflowDecisionTest { + @Test + void decisionSurvivesSerializationAndRetainsItsExactTarget() { + Task task = task(); + OntologyWorkflowDecision decision = decision(task); + OntologyWorkflowDecision restored = + JsonUtils.readValue(JsonUtils.pojoToJson(decision), OntologyWorkflowDecision.class); + + restored.requireTarget(task); + assertEquals(decision, restored); + assertEquals(List.of("alice", "bob"), restored.voters()); + } + + @Test + void replayCannotResolveAnotherTaskOrRevision() { + Task task = task(); + OntologyWorkflowDecision decision = decision(task); + assertThrows( + BadRequestException.class, + () -> + decision.requireTarget(JsonUtils.deepCopy(task, Task.class).withId(UUID.randomUUID()))); + task.getOntologyReview().setContentDigest("b".repeat(64)); + assertThrows(BadRequestException.class, () -> decision.requireTarget(task)); + } + + @Test + void receiptRequiresAnExplicitHumanDecisionAndItsVoter() { + Task task = task(); + for (TaskResolutionType type : + List.of(TaskResolutionType.AutoApproved, TaskResolutionType.Completed)) { + assertThrows( + BadRequestException.class, + () -> + new OntologyWorkflowDecision( + task.getId(), task.getOntologyReview(), type, "bob", List.of("bob"), null, null)); + } + assertThrows( + BadRequestException.class, + () -> + new OntologyWorkflowDecision( + task.getId(), + task.getOntologyReview(), + TaskResolutionType.Approved, + "bob", + List.of("alice"), + null, + null)); + } + + private static Task task() { + return new Task() + .withId(UUID.randomUUID()) + .withOntologyReview( + new OntologyReviewTarget() + .withEntityId(UUID.randomUUID()) + .withContentDigest("a".repeat(64)) + .withContentDigest("reviewed")); + } + + private static OntologyWorkflowDecision decision(Task task) { + return new OntologyWorkflowDecision( + task.getId(), + task.getOntologyReview(), + TaskResolutionType.Approved, + "bob", + List.of("alice", "bob"), + null, + "Reviewed"); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyActiveGraphStorageTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyActiveGraphStorageTest.java new file mode 100644 index 000000000000..e45b8520c934 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyActiveGraphStorageTest.java @@ -0,0 +1,214 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.openmetadata.service.jdbi3.OntologyCaptureFixture.withCapture; +import static org.openmetadata.service.jdbi3.OntologyTestDatabase.repositoryRoot; + +import jakarta.json.Json; +import java.time.Clock; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.jena.atlas.iterator.Iter; +import org.apache.jena.query.Dataset; +import org.apache.jena.query.DatasetFactory; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.update.UpdateAction; +import org.apache.jena.vocabulary.RDFS; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.openmetadata.schema.api.data.ActivateOntologyRelease; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.service.jdbi3.OntologyCaptureFixture.Fixture; +import org.openmetadata.service.jdbi3.locator.ConnectionType; +import org.openmetadata.service.migration.utils.MigrationFile; +import org.openmetadata.service.ontology.OntologyReleaseActivation; +import org.openmetadata.service.rdf.OntologyActiveGraph; +import org.openmetadata.service.rdf.RdfLiveWriteStore; +import org.openmetadata.service.rdf.storage.RdfStorageInterface; + +class OntologyActiveGraphStorageTest { + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void rebuildCannotOverwriteAnActivationDeliveredWhileItsModelWasBeingBuilt( + ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + migrateQueue(fixture, dialect); + final var first = fixture.capture(ignored -> {}); + activate(fixture, first, 0); + fixture.ontologies.patch( + null, + fixture.ontology.getId(), + "admin", + Json.createPatchBuilder().add("/displayName", "New active release").build()); + final var second = fixture.capture(ignored -> {}); + final var queue = new RdfLiveWriteStore(fixture.handle.getJdbi(), Clock.systemUTC()); + final var projection = new OntologyActiveGraph(fixture.releases.getDaoCollection()); + final var modelBuilt = new CountDownLatch(1); + final var resumeRebuild = new CountDownLatch(1); + final var pause = new AtomicBoolean(true); + final Dataset dataset = DatasetFactory.createTxnMem(); + try (var workers = Executors.newSingleThreadExecutor()) { + final var storage = storage(dataset); + doAnswer( + call -> { + if (pause.compareAndSet(true, false)) { + modelBuilt.countDown(); + assertTrue(resumeRebuild.await(10, TimeUnit.SECONDS)); + } + return call.callRealMethod(); + }) + .when(storage) + .replaceGraph(anyString(), any(Model.class)); + final var rebuilding = workers.submit(() -> projection.rebuild(storage)); + try { + assertTrue(modelBuilt.await(10, TimeUnit.SECONDS)); + activate(fixture, second, 1); + queue.enqueue("refresh"); + queue.processNext(ignored -> projection.refresh(fixture.ontology.getId(), storage)); + } finally { + resumeRebuild.countDown(); + } + rebuilding.get(10, TimeUnit.SECONDS); + queue.processNext(ignored -> projection.refresh(fixture.ontology.getId(), storage)); + assertEquals(0, queue.pendingWrites()); + assertTrue( + dataset + .getNamedModel(OntologyActiveGraph.graph(fixture.ontology.getId())) + .contains(null, RDFS.label, "New active release")); + } catch (InterruptedException | ExecutionException | TimeoutException failure) { + throw new AssertionError(failure); + } finally { + dataset.close(); + } + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void projectionFollowsOnlyTheActiveReleaseAndRebuildRemovesArchivedGraphs( + ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + migrateQueue(fixture, dialect); + final Dataset dataset = DatasetFactory.createTxnMem(); + try { + final var storage = storage(dataset); + final var projection = new OntologyActiveGraph(fixture.releases.getDaoCollection()); + final var id = fixture.ontology.getId(); + projection.refresh(id, storage); + assertTrue(dataset.getNamedModel(OntologyActiveGraph.graph(id)).isEmpty()); + final var first = fixture.capture(ignored -> {}); + activate(fixture, first, 0); + projection.refresh(id, storage); + final Model original = dataset.getNamedModel(OntologyActiveGraph.graph(id)); + assertTrue(original.contains(null, RDFS.label, fixture.ontology.getName())); + fixture.ontologies.patch( + null, + id, + "admin", + Json.createPatchBuilder().add("/displayName", "Working label").build()); + projection.refresh(id, storage); + assertFalse( + dataset + .getNamedModel(OntologyActiveGraph.graph(id)) + .contains(null, RDFS.label, "Working label")); + final var second = fixture.capture(ignored -> {}); + activate(fixture, second, 1); + projection.refresh(id, storage); + assertTrue( + dataset + .getNamedModel(OntologyActiveGraph.graph(id)) + .contains(null, RDFS.label, "Working label")); + activate(fixture, first, 2); + projection.rebuild(storage); + assertFalse( + dataset + .getNamedModel(OntologyActiveGraph.graph(id)) + .contains(null, RDFS.label, "Working label")); + fixture.ontologies.delete("admin", id, false, false); + projection.rebuild(storage); + assertTrue(dataset.getNamedModel(OntologyActiveGraph.graph(id)).isEmpty()); + fixture.ontologies.restoreEntity("admin", id); + projection.rebuild(storage); + assertTrue( + dataset + .getNamedModel(OntologyActiveGraph.graph(id)) + .contains(null, RDFS.label, fixture.ontology.getName())); + final var orphan = OntologyActiveGraph.graph(UUID.randomUUID()); + storage.executeSparqlUpdate( + "INSERT DATA { GRAPH <" + orphan + "> { } }"); + projection.rebuild(storage); + assertTrue(dataset.getNamedModel(orphan).isEmpty()); + } finally { + dataset.close(); + } + }); + } + + private static void migrateQueue(Fixture fixture, ConnectionType dialect) { + for (final String version : List.of("2.0.2", "2.1.0")) { + final var migration = + repositoryRoot() + .resolve("bootstrap/sql/migrations/native") + .resolve(version) + .resolve(dialect == ConnectionType.MYSQL ? "mysql" : "postgres") + .resolve("schemaChanges.sql"); + MigrationFile.parseSQLFile(migration.toFile(), dialect).stream() + .filter( + statement -> + statement.contains("rdf_live_write") + || statement.contains("rdf_projection_health")) + .forEach(fixture.handle::execute); + } + } + + private static void activate(Fixture fixture, OntologyRelease release, long expected) { + fixture + .releases + .getDaoCollection() + .ontologyReleaseDAO() + .update(release.withEntityStatus(EntityStatus.APPROVED)); + new OntologyReleaseActivation(fixture.ontologies, Clock.systemUTC(), ignored -> {}) + .activate( + fixture.ontology.getId(), + new ActivateOntologyRelease() + .withReleaseId(release.getId()) + .withExpectedActivationRevision(expected), + new OntologyReleaseActivation.Access("admin", ignored -> {})); + } + + private static RdfStorageInterface storage(Dataset dataset) { + final var storage = mock(RdfStorageInterface.class, CALLS_REAL_METHODS); + doAnswer( + call -> { + UpdateAction.parseExecute(call.getArgument(0), dataset); + return null; + }) + .when(storage) + .executeSparqlUpdate(anyString()); + when(storage.getAllGraphs()).thenAnswer(ignored -> Iter.toList(dataset.listNames())); + return storage; + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyCaptureFixture.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyCaptureFixture.java new file mode 100644 index 000000000000..31a788837422 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyCaptureFixture.java @@ -0,0 +1,131 @@ +package org.openmetadata.service.jdbi3; + +import static org.openmetadata.service.jdbi3.OntologyTestDatabase.migrate; +import static org.openmetadata.service.jdbi3.OntologyTestDatabase.withDatabase; + +import java.net.URI; +import java.time.Clock; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.stream.IntStream; +import org.jdbi.v3.core.Handle; +import org.openmetadata.schema.api.data.CaptureOntologyRelease; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.locator.ConnectionType; +import org.openmetadata.service.ontology.OntologyReleaseCapture; + +final class OntologyCaptureFixture { + private OntologyCaptureFixture() {} + + static void withCapture(ConnectionType dialect, Consumer assertion) { + withDatabase( + dialect, + handle -> { + migrate(handle, dialect); + CollectionDAO previous = Entity.getCollectionDAO(); + SystemRepository previousSettings = Entity.getSystemRepository(); + EntityRelationshipRepository previousRelationships = + Entity.getEntityRelationshipRepository(); + Entity.setCollectionDAO(handle.getJdbi().onDemand(CollectionDAO.class)); + Entity.setEntityRelationshipRepository( + new EntityRelationshipRepository(Entity.getCollectionDAO())); + try { + new SystemRepository(); + assertion.accept(new Fixture(handle)); + } finally { + Entity.setCollectionDAO(previous); + Entity.setSystemRepository(previousSettings); + Entity.setEntityRelationshipRepository(previousRelationships); + } + }); + } + + static final class Fixture { + final Handle handle; + final OntologyRepository ontologies = new OntologyRepository(); + final OntologyConceptRepository concepts = new OntologyConceptRepository(); + final OntologyReleaseRepository releases = new OntologyReleaseRepository(); + final OntologyReleaseCapture capture = + new OntologyReleaseCapture( + releases, + ontologies, + Map.of( + MemberKind.CONCEPT, concepts, + MemberKind.PROPERTY, new OntologyPropertyRepository(), + MemberKind.AXIOM, new OntologyAxiomRepository()), + Clock.systemUTC()); + final Ontology ontology; + private UUID lastCaptureId; + + private Fixture(Handle handle) { + this.handle = handle; + ontology = + ontologies.create( + null, + new Ontology() + .withId(UUID.randomUUID()) + .withName("Capture" + UUID.randomUUID()) + .withIri(URI.create("urn:ontology:capture")) + .withNamespace(URI.create("https://example.org/capture/")) + .withVersion(0.1) + .withUpdatedAt(1L) + .withUpdatedBy("admin")); + } + + OntologyRelease capture(Consumer authorize) { + OntologyRelease release = + capture.capture( + ontology.getId(), + new CaptureOntologyRelease() + .withExpectedWorkingRevision( + ontologies.getState(ontology.getId()).getWorkingRevision()), + new OntologyReleaseCapture.Access( + "admin", + definitions -> + definitions.forEach( + definition -> authorize.accept(definition.getEntityReference())))); + lastCaptureId = release.getId(); + return release; + } + + void addConcepts(int count) { + List members = + IntStream.range(0, count) + .mapToObj( + index -> + new OntologyConcept() + .withId(UUID.randomUUID()) + .withName("Concept" + index) + .withOntology(ontology.getEntityReference()) + .withConceptType(OntologyConceptType.OWL_CLASS) + .withVersion(0.1) + .withUpdatedAt(1L) + .withUpdatedBy("admin")) + .toList(); + members.forEach(member -> concepts.create(null, member)); + } + + int rowCount(String table) { + boolean member = table.equals("ontology_release_member"); + UUID id = member ? lastCaptureId : ontology.getId(); + return handle + .createQuery( + "SELECT COUNT(*) FROM " + + table + + " WHERE " + + (member ? "releaseId" : "ontologyId") + + " = :id") + .bind("id", id.toString()) + .mapTo(Integer.class) + .one(); + } + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyEntityDAOTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyEntityDAOTest.java new file mode 100644 index 000000000000..2d6209fe3515 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyEntityDAOTest.java @@ -0,0 +1,1082 @@ +/* + * Copyright 2026 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.openmetadata.service.jdbi3.OntologyTestDatabase.PASSWORD; +import static org.openmetadata.service.jdbi3.OntologyTestDatabase.migrate; +import static org.openmetadata.service.jdbi3.OntologyTestDatabase.migrateRepositoryTables; +import static org.openmetadata.service.jdbi3.OntologyTestDatabase.withDatabase; + +import java.net.URI; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import org.flowable.engine.ProcessEngineConfiguration; +import org.jdbi.v3.core.Handle; +import org.jdbi.v3.core.statement.SqlLogger; +import org.jdbi.v3.core.statement.StatementContext; +import org.jdbi.v3.core.statement.UnableToExecuteStatementException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.governance.workflows.WorkflowDefinition; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.Relationship; +import org.openmetadata.schema.type.RelationshipCharacteristic; +import org.openmetadata.schema.type.TaskCategory; +import org.openmetadata.schema.type.TaskEntityStatus; +import org.openmetadata.schema.type.TaskEntityType; +import org.openmetadata.schema.type.TaskResolution; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.OpenMetadataApplicationConfig; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.exception.EntityNotFoundException; +import org.openmetadata.service.exception.PreconditionFailedException; +import org.openmetadata.service.governance.workflows.OntologyReviewReconciler; +import org.openmetadata.service.governance.workflows.OntologyWorkflowDecision; +import org.openmetadata.service.governance.workflows.WorkflowHandler; +import org.openmetadata.service.governance.workflows.WorkflowHandler.TaskResolutionOutcome; +import org.openmetadata.service.jdbi3.locator.ConnectionType; +import org.openmetadata.service.ontology.OntologyReview; + +class OntologyEntityDAOTest { + private static final URI NAMESPACE = URI.create("https://example.org/sales/"); + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void migrationResetsOnlyUnreleasedGlossaryAxioms(ConnectionType dialect) { + withDatabase( + dialect, + handle -> { + migrateRepositoryTables(handle, dialect); + final var axiomId = UUID.randomUUID(); + final var glossaryId = UUID.randomUUID(); + String legacy = + """ + {"id":"%s","name":"legacy","glossary":{"id":"%s"}, + "axiomType":"SubClassOf","entityStatus":"Draft","updatedAt":1, + "updatedBy":"admin","deleted":false} + """ + .formatted(axiomId, glossaryId); + handle + .createUpdate( + "INSERT INTO ontology_axiom_entity (fqnHash, json) VALUES (:hash, " + + (dialect == ConnectionType.POSTGRES ? "CAST(:json AS JSONB)" : ":json") + + ")") + .bind("hash", "legacy-axiom") + .bind("json", legacy) + .execute(); + handle + .createUpdate( + "INSERT INTO entity_relationship (fromId, toId, fromEntity, toEntity, relation) VALUES (:parent, :child, 'glossary', 'ontologyAxiom', 0)") + .bind("parent", glossaryId.toString()) + .bind("child", axiomId.toString()) + .execute(); + handle + .createUpdate( + "INSERT INTO entity_extension (id, extension, jsonSchema, json) VALUES (:id, 'ontologyAxiom.version.0.1', 'ontologyAxiom', " + + (dialect == ConnectionType.POSTGRES ? "CAST(:json AS JSONB)" : ":json") + + ")") + .bind("id", axiomId.toString()) + .bind("json", legacy) + .execute(); + migrate(handle, dialect); + assertEquals( + 0, + handle + .createQuery("SELECT COUNT(*) FROM entity_relationship WHERE fromId = :id") + .bind("id", glossaryId.toString()) + .mapTo(Integer.class) + .one()); + assertEquals( + 0, + handle + .createQuery("SELECT COUNT(*) FROM entity_extension WHERE id = :id") + .bind("id", axiomId.toString()) + .mapTo(Integer.class) + .one()); + assertEquals( + 0, + handle + .createQuery("SELECT COUNT(*) FROM ontology_axiom_entity") + .mapTo(Integer.class) + .one()); + Ontology ontology = ontology(); + handle + .attach(CollectionDAO.class) + .ontologyEntityDAO() + .insert(ontology, ontology.getFullyQualifiedName()); + handle + .createUpdate( + "INSERT INTO ontology_axiom_entity (fqnHash, json) VALUES (:hash, " + + (dialect == ConnectionType.POSTGRES ? "CAST(:json AS JSONB)" : ":json") + + ")") + .bind("hash", "native-axiom") + .bind("json", legacy.replace("glossary", "ontology")) + .execute(); + migrate(handle, dialect); + assertEquals( + ontology, + handle + .attach(CollectionDAO.class) + .ontologyEntityDAO() + .findEntityById(ontology.getId())); + assertEquals( + 1, + handle + .createQuery("SELECT COUNT(*) FROM ontology_axiom_entity") + .mapTo(Integer.class) + .one()); + String listingIndexes = + "('ontology_concept_listing_index', 'ontology_property_listing_index', 'ontology_axiom_listing_index')"; + String indexQuery = + dialect == ConnectionType.MYSQL + ? "SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema = DATABASE() AND index_name IN " + : "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = current_schema() AND indexname IN "; + assertEquals( + 3, handle.createQuery(indexQuery + listingIndexes).mapTo(Integer.class).one()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void migrationPreservesTermChangeSetsWhileResettingLegacyAxiomOperations(ConnectionType dialect) { + withDatabase( + dialect, + handle -> { + migrateRepositoryTables(handle, dialect); + final String jsonValue = + dialect == ConnectionType.POSTGRES ? "CAST(:json AS JSONB)" : ":json"; + final var glossaryId = UUID.randomUUID().toString(); + for (String operation : + List.of("UPSERT_AXIOM", "DELETE_AXIOM", "UPSERT_TERM", "NATIVE_UPSERT_AXIOM")) { + final String id = UUID.randomUUID().toString(); + final boolean nativeDraft = operation.startsWith("NATIVE_"); + final String json = + """ + {"id":"%s","name":"%s",%s"state":"DRAFT","updatedAt":1, + "updatedBy":"admin","deleted":false,"operations":[{"operationType":"%s"}]} + """ + .formatted( + id, + operation, + nativeDraft ? "\"ontology\":{\"id\":\"" + glossaryId + "\"}," : "", + operation.replace("NATIVE_", "")); + handle + .createUpdate( + "INSERT INTO ontology_change_set_entity (fqnHash, json) VALUES (:hash, " + + jsonValue + + ")") + .bind("hash", operation) + .bind("json", json) + .execute(); + handle + .createUpdate( + "INSERT INTO entity_relationship (fromId, toId, fromEntity, toEntity, relation) VALUES (:parent, :child, :parentType, 'ontologyChangeSet', 0)") + .bind("parent", glossaryId) + .bind("child", id) + .bind("parentType", nativeDraft ? "ontology" : "glossary") + .execute(); + handle + .createUpdate( + "INSERT INTO entity_extension (id, extension, jsonSchema, json) VALUES (:id, 'ontologyChangeSet.version.0.1', 'ontologyChangeSet', " + + jsonValue + + ")") + .bind("id", id) + .bind("json", json) + .execute(); + } + migrate(handle, dialect); + migrate(handle, dialect); + assertEquals( + List.of("NATIVE_UPSERT_AXIOM", "UPSERT_TERM"), + handle + .createQuery("SELECT name FROM ontology_change_set_entity ORDER BY name") + .mapTo(String.class) + .list()); + assertEquals( + 2, + handle + .createQuery("SELECT COUNT(*) FROM entity_relationship WHERE fromId = :id") + .bind("id", glossaryId) + .mapTo(Integer.class) + .one()); + assertEquals( + 2, + handle + .createQuery( + "SELECT COUNT(*) FROM entity_extension WHERE jsonSchema = 'ontologyChangeSet'") + .mapTo(Integer.class) + .one()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void storesIndependentEntitiesThroughStandardDaos(ConnectionType dialect) { + withDatabase(dialect, handle -> assertStorageContract(handle, dialect)); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void existingWorkflowEngineCountsDistinctVotesAndFailsClosed(ConnectionType dialect) { + withDatabase(dialect, handle -> assertWorkflowVoting(handle, dialect)); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void cancellingTheCurrentReviewReturnsTheDefinitionToDraft(ConnectionType dialect) { + withReviewRepositories( + dialect, + fixture -> { + Task cancelled = fixture.cancel(); + OntologyRelease definition = fixture.definition(); + assertEquals(TaskEntityStatus.Cancelled, cancelled.getStatus()); + assertEquals(EntityStatus.DRAFT, definition.getEntityStatus()); + assertNull(definition.getReviewTask()); + assertEquals(1L, definition.getReleaseRevision()); + assertEquals("admin", definition.getReviewAuthor()); + Task closedAgain = + fixture + .tasks() + .resolveTask( + cancelled, + new TaskResolution() + .withType(TaskResolutionType.Cancelled) + .withComment("A repeated cancellation must preserve the first decision"), + "admin"); + assertEquals(cancelled.getResolution(), closedAgain.getResolution()); + assertEquals(cancelled.getVersion(), closedAgain.getVersion()); + assertThrows( + BadRequestException.class, + () -> fixture.tasks().reopenTaskWithWorkflow(cancelled, "admin")); + assertEquals(TaskEntityStatus.Cancelled, fixture.storedTask().getStatus()); + assertEquals(EntityStatus.DRAFT, fixture.definition().getEntityStatus()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void cancellingASupersededReviewDoesNotChangeTheNewReview(ConnectionType dialect) { + withReviewRepositories( + dialect, + fixture -> { + OntologyRelease newer = fixture.definition().withReviewTask(UUID.randomUUID()); + Entity.getCollectionDAO().ontologyReleaseDAO().update(newer); + fixture.cancel(); + assertEquals(newer, fixture.definition()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void cancellationRollsBackWithItsTaskAndRejectsStaleTaskWrites(ConnectionType dialect) { + withReviewRepositories( + dialect, + fixture -> { + assertThrows( + IllegalStateException.class, + () -> + fixture + .tasks() + .executeInTransaction( + () -> { + fixture.cancel(); + throw new IllegalStateException("Roll back task cancellation"); + })); + assertEquals(TaskEntityStatus.Open, fixture.storedTask().getStatus()); + assertEquals(EntityStatus.IN_REVIEW, fixture.definition().getEntityStatus()); + assertNull( + Entity.getCollectionDAO() + .entityExtensionDAO() + .getExtension(fixture.task().getId(), OntologyReviewReconciler.EXTENSION)); + fixture.cancel(); + assertNotNull( + Entity.getCollectionDAO() + .entityExtensionDAO() + .getExtension(fixture.task().getId(), OntologyReviewReconciler.EXTENSION)); + assertThrows( + PreconditionFailedException.class, + () -> + fixture + .tasks() + .update( + null, + fixture.task(), + JsonUtils.deepCopy(fixture.task(), Task.class) + .withDescription("Late task edit"), + "admin")); + assertEquals(TaskEntityStatus.Cancelled, fixture.storedTask().getStatus()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void cancellationCannotHoldADefinitionWhileWaitingForItsModule(ConnectionType dialect) { + withReviewRepositories( + dialect, + (handle, fixture) -> { + CollectionDAO dao = Entity.getCollectionDAO(); + dao.ontologyStateDAO().initialize(fixture.ontologyId()); + try (var executor = Executors.newSingleThreadExecutor()) { + handle.begin(); + handle.attach(CollectionDAO.class).ontologyStateDAO().lock(fixture.ontologyId()); + final CountDownLatch waitingForModule = signalModuleLock(handle); + var cancellation = executor.submit(fixture::cancel); + try { + assertTrue(assertDoesNotThrow(() -> waitingForModule.await(10, TimeUnit.SECONDS))); + assertDoesNotThrow( + () -> + handle + .createQuery( + "SELECT json FROM ontology_entity WHERE id = :id FOR UPDATE NOWAIT") + .bind("id", fixture.ontologyId().toString()) + .mapTo(String.class) + .one()); + } finally { + handle.rollback(); + } + assertEquals( + TaskEntityStatus.Cancelled, + assertDoesNotThrow(() -> cancellation.get(10, TimeUnit.SECONDS)).getStatus()); + assertEquals(EntityStatus.DRAFT, fixture.definition().getEntityStatus()); + } + }); + } + + @ParameterizedTest + @CsvSource({"MYSQL,false", "MYSQL,true", "POSTGRES,false", "POSTGRES,true"}) + void reviewLocksSeeEditsAndArchivalCommittedWhileWaiting( + ConnectionType dialect, boolean archived) { + withReviewRepositories( + dialect, + (handle, fixture) -> { + final var dao = Entity.getCollectionDAO(); + final var repository = + (OntologyReleaseRepository) Entity.getEntityRepository(Entity.ONTOLOGY_RELEASE); + dao.workflowDefinitionDAO() + .insert( + new WorkflowDefinition() + .withId(fixture.task().getWorkflowDefinitionId()) + .withName("review") + .withVersion(0.1) + .withUpdatedAt(1L) + .withUpdatedBy("admin"), + "review"); + final OntologyRelease changed = + fixture.definition().withVersion(0.2).withDeleted(archived); + dao.ontologyStateDAO().initialize(fixture.ontologyId()); + try (var executor = Executors.newSingleThreadExecutor()) { + handle.begin(); + handle.attach(CollectionDAO.class).ontologyStateDAO().lock(fixture.ontologyId()); + final CountDownLatch waitingForModule = signalModuleLock(handle); + final var review = + executor.submit( + () -> + repository.executeInTransaction( + () -> { + repository.lockReviewTarget(fixture.task()); + return repository.get( + null, + fixture.task().getAbout().getId(), + repository.getFields(""), + Include.NON_DELETED, + false); + })); + try { + assertTrue(assertDoesNotThrow(() -> waitingForModule.await(10, TimeUnit.SECONDS))); + handle.attach(CollectionDAO.class).ontologyReleaseDAO().update(changed); + handle.commit(); + } finally { + if (handle.isInTransaction()) { + handle.rollback(); + } + } + if (archived) { + assertTrue( + assertThrows(ExecutionException.class, () -> review.get(10, TimeUnit.SECONDS)) + .getCause() + instanceof EntityNotFoundException); + } else { + assertEquals( + changed.getVersion(), + assertDoesNotThrow(() -> review.get(10, TimeUnit.SECONDS)).getVersion()); + } + } + }); + } + + private static CountDownLatch signalModuleLock(final Handle handle) { + final var waiting = new CountDownLatch(1); + handle + .getJdbi() + .setSqlLogger( + new SqlLogger() { + @Override + public void logBeforeExecution(StatementContext context) { + if (context.getRenderedSql().contains("ontology_state")) { + waiting.countDown(); + } + } + }); + return waiting; + } + + private record ReviewFixture(TaskRepository tasks, Task task, UUID ontologyId) { + Task cancel() { + return tasks + .update( + null, + task, + JsonUtils.deepCopy(task, Task.class) + .withStatus(TaskEntityStatus.Cancelled) + .withResolution(new TaskResolution().withType(TaskResolutionType.Cancelled)), + "admin") + .getEntity(); + } + + OntologyRelease definition() { + return Entity.getCollectionDAO().ontologyReleaseDAO().findEntityById(task.getAbout().getId()); + } + + Task storedTask() { + return Entity.getCollectionDAO().taskDAO().findEntityById(task.getId()); + } + } + + private static void withReviewRepositories( + ConnectionType dialect, Consumer assertion) { + withReviewRepositories(dialect, (handle, fixture) -> assertion.accept(fixture)); + } + + private static void withReviewRepositories( + ConnectionType dialect, BiConsumer assertion) { + withDatabase( + dialect, + handle -> { + migrate(handle, dialect); + migrateRepositoryTables(handle, dialect); + CollectionDAO previous = Entity.getCollectionDAO(); + SystemRepository previousSettings = Entity.getSystemRepository(); + EntityRelationshipRepository previousRelationships = + Entity.getEntityRelationshipRepository(); + Entity.setCollectionDAO(handle.getJdbi().onDemand(CollectionDAO.class)); + Entity.setEntityRelationshipRepository( + new EntityRelationshipRepository(Entity.getCollectionDAO())); + try { + new SystemRepository(); + new WorkflowDefinitionRepository(); + new OntologyRepository(); + new OntologyReleaseRepository(); + assertion.accept(handle, reviewFixture(new TaskRepository())); + } finally { + Entity.setCollectionDAO(previous); + Entity.setSystemRepository(previousSettings); + Entity.setEntityRelationshipRepository(previousRelationships); + } + }); + } + + private static ReviewFixture reviewFixture(TaskRepository tasks) { + UUID taskId = UUID.randomUUID(); + String name = "Review" + taskId; + Ontology root = ontology(); + Entity.getCollectionDAO().ontologyEntityDAO().insert(root, root.getFullyQualifiedName()); + OntologyRelease definition = + new OntologyRelease() + .withId(UUID.randomUUID()) + .withName(name) + .withFullyQualifiedName(name) + .withOntology(root.getEntityReference()) + .withReleaseRevision(1L) + .withSourceWorkingRevision(0L) + .withVersion(0.1) + .withUpdatedBy("admin") + .withUpdatedAt(1L) + .withEntityStatus(EntityStatus.IN_REVIEW) + .withReviewAuthor("admin") + .withReviewTask(taskId); + Entity.getCollectionDAO().ontologyReleaseDAO().insert(definition, name); + Task task = + new Task() + .withId(taskId) + .withName(name) + .withTaskId("TASK-" + taskId.toString().substring(0, 8)) + .withCategory(TaskCategory.Approval) + .withType(TaskEntityType.RequestApproval) + .withStatus(TaskEntityStatus.Open) + .withAbout(definition.getEntityReference()) + .withOntologyReview(OntologyReview.target(definition)) + .withWorkflowDefinitionId(UUID.randomUUID()) + .withVersion(0.1) + .withCreatedAt(1L) + .withUpdatedAt(1L) + .withUpdatedBy("admin"); + tasks.setFullyQualifiedName(task); + Entity.getCollectionDAO() + .taskDAO() + .insertTask( + task.getId().toString(), + JsonUtils.pojoToJson(JsonUtils.deepCopy(task, Task.class).withAbout(null)), + task.getFullyQualifiedName()); + tasks.addRelationship( + definition.getId(), + taskId, + Entity.ONTOLOGY_RELEASE, + Entity.TASK, + Relationship.MENTIONED_IN); + return new ReviewFixture(tasks, task, root.getId()); + } + + private static void assertWorkflowVoting(Handle handle, ConnectionType dialect) { + migrateRepositoryTables(handle, dialect); + CollectionDAO previousDao = Entity.getCollectionDAO(); + SystemRepository previousSettings = Entity.getSystemRepository(); + Entity.setCollectionDAO(handle.attach(CollectionDAO.class)); + new SystemRepository(); + try { + WorkflowHandler.initialize(workflowConfig(handle, dialect), true); + WorkflowHandler workflow = WorkflowHandler.getInstance(); + ProcessEngineConfiguration engine = workflow.getProcessEngineConfiguration(); + engine + .getRepositoryService() + .createDeployment() + .addString("ontology-vote-test.bpmn20.xml", votingProcess()) + .deploy(); + UUID taskId = UUID.randomUUID(); + engine + .getRuntimeService() + .startProcessInstanceByKey( + "ontologyVoteTest", + Map.of( + "customTaskId", + taskId.toString(), + "approvalThreshold", + 2, + "rejectionThreshold", + 1)); + + assertEquals( + TaskResolutionOutcome.WAITING_FOR_VOTES, + workflow.resolveTaskOutcome(taskId, vote("alice"))); + assertEquals(List.of("alice"), workflow.approvalTaskContext(taskId).voters()); + assertEquals("Review", workflow.approvalTaskContext(taskId).nodeName()); + assertEquals( + TaskResolutionOutcome.WAITING_FOR_VOTES, + workflow.resolveTaskOutcome(taskId, vote("alice"))); + assertEquals(List.of("alice"), workflow.approvalTaskContext(taskId).voters()); + assertEquals( + TaskResolutionOutcome.COMPLETED, workflow.resolveTaskOutcome(taskId, vote("bob"))); + assertEquals( + 0, + engine + .getTaskService() + .createTaskQuery() + .processVariableValueEquals("customTaskId", taskId.toString()) + .count()); + assertEquals( + TaskResolutionOutcome.RESOLUTION_FAILED, + workflow.resolveTaskOutcome(taskId, vote("bob"))); + assertContinuingWorkflowIsNotComplete(workflow, engine); + assertFailedWorkflowIsNotComplete(workflow, engine); + assertLegacyApprovalCompletesWhenTheProcessFails(workflow, engine); + assertDecisionSurvivesAsyncCompletion(workflow, engine); + } finally { + WorkflowHandler.shutDown(); + Entity.setCollectionDAO(previousDao); + Entity.setSystemRepository(previousSettings); + } + } + + private static void assertDecisionSurvivesAsyncCompletion( + WorkflowHandler workflow, ProcessEngineConfiguration engine) { + String process = + votingProcess() + .replace("ontologyVoteTest", "ontologyAsyncReview") + .replace("targetRef=\"end\"", "targetRef=\"afterReview\"") + .replace( + "", + ""); + engine + .getRepositoryService() + .createDeployment() + .addString("ontology-async-review.bpmn20.xml", process) + .deploy(); + UUID taskId = UUID.randomUUID(); + OntologyWorkflowDecision decision = + new OntologyWorkflowDecision( + taskId, + OntologyReview.target( + new OntologyRelease() + .withId(UUID.randomUUID()) + .withOntology(reference(ontology())) + .withVersion(0.1) + .withReviewAuthor("author")), + TaskResolutionType.Approved, + "bob", + List.of("bob"), + null, + "Approved before async work"); + String processId = + engine + .getRuntimeService() + .startProcessInstanceByKey( + "ontologyAsyncReview", + Map.of("customTaskId", taskId.toString(), "approvalThreshold", 2)) + .getId(); + assertEquals( + TaskResolutionOutcome.WAITING_FOR_VOTES, + workflow.resolveTaskOutcome(taskId, vote("alice"))); + Map variables = vote("bob"); + variables.put(OntologyWorkflowDecision.VARIABLE, JsonUtils.pojoToJson(decision)); + assertEquals( + TaskResolutionOutcome.STAGE_ADVANCED, workflow.resolveTaskOutcome(taskId, variables)); + assertTrue(workflow.completedOntologyDecision(processId).isEmpty()); + + String executionId = + engine + .getRuntimeService() + .createExecutionQuery() + .processInstanceId(processId) + .activityId("afterReview") + .singleResult() + .getId(); + engine.getRuntimeService().trigger(executionId); + OntologyWorkflowDecision committed = + workflow.completedOntologyDecision(processId).orElseThrow(); + assertEquals(taskId, committed.taskId()); + assertEquals(decision.target(), committed.target()); + assertEquals(Set.of("alice", "bob"), Set.copyOf(committed.voters())); + + workflow.initializeNewProcessEngine(engine); + assertEquals(committed, workflow.completedOntologyDecision(processId).orElseThrow()); + assertEquals(committed, workflow.completedOntologyDecision(processId).orElseThrow()); + } + + private static void assertContinuingWorkflowIsNotComplete( + WorkflowHandler workflow, ProcessEngineConfiguration engine) { + String process = + votingProcess() + .replace("ontologyVoteTest", "ontologyContinuedReview") + .replace("targetRef=\"end\"", "targetRef=\"additionalReview\"") + .replace( + "", + ""); + engine + .getRepositoryService() + .createDeployment() + .addString("ontology-continued-review.bpmn20.xml", process) + .deploy(); + UUID taskId = UUID.randomUUID(); + engine + .getRuntimeService() + .startProcessInstanceByKey( + "ontologyContinuedReview", Map.of("customTaskId", taskId.toString())); + + assertEquals( + TaskResolutionOutcome.STAGE_ADVANCED, workflow.resolveTaskOutcome(taskId, vote("alice"))); + assertEquals( + 1, + engine + .getTaskService() + .createTaskQuery() + .processVariableValueEquals("customTaskId", taskId.toString()) + .count()); + } + + private static Map vote(String user) { + return new HashMap<>(Map.of("Review_updatedBy", user, "Review_result", "approve")); + } + + private static void assertFailedWorkflowIsNotComplete( + WorkflowHandler workflow, ProcessEngineConfiguration engine) { + for (String failureVariable : + List.of("failure", "global_failure", "exception", "global_exception")) { + UUID taskId = UUID.randomUUID(); + engine + .getRuntimeService() + .startProcessInstanceByKey( + "ontologyVoteTest", Map.of("customTaskId", taskId.toString(), failureVariable, true)); + assertEquals( + TaskResolutionOutcome.PROCESS_FAILED, + workflow.resolveTaskOutcome(taskId, vote("alice")), + failureVariable); + } + engine + .getRepositoryService() + .createDeployment() + .addString( + "ontology-error-review.bpmn20.xml", + votingProcess() + .replace("ontologyVoteTest", "ontologyErrorReview") + .replace("\"end\"", "\"Error\"")) + .deploy(); + UUID taskId = UUID.randomUUID(); + engine + .getRuntimeService() + .startProcessInstanceByKey( + "ontologyErrorReview", Map.of("customTaskId", taskId.toString())); + assertEquals( + TaskResolutionOutcome.PROCESS_FAILED, workflow.resolveTaskOutcome(taskId, vote("alice"))); + } + + private static void assertLegacyApprovalCompletesWhenTheProcessFails( + WorkflowHandler workflow, ProcessEngineConfiguration engine) { + for (int threshold : List.of(1, 2)) { + UUID taskId = UUID.randomUUID(); + String processId = + engine + .getRuntimeService() + .startProcessInstanceByKey( + "ontologyErrorReview", + Map.of("customTaskId", taskId.toString(), "approvalThreshold", threshold)) + .getId(); + assertTrue(workflow.resolveTask(taskId, vote("alice"))); + if (threshold == 2) { + assertTrue(workflow.resolveTask(taskId, vote("bob"))); + } + assertEquals( + 0, engine.getTaskService().createTaskQuery().processInstanceId(processId).count()); + assertTrue(workflow.completedOntologyDecision(processId).isEmpty()); + } + } + + private static OpenMetadataApplicationConfig workflowConfig( + Handle handle, ConnectionType dialect) { + String externalUrl = + System.getProperty("ontology.test." + dialect.name().toLowerCase(Locale.ROOT) + ".url"); + HikariCPDataSourceFactory dataSource = new HikariCPDataSourceFactory(); + dataSource.setDriverClass(dialect.label); + try { + dataSource.setUrl( + externalUrl == null ? handle.getConnection().getMetaData().getURL() : externalUrl); + } catch (SQLException exception) { + throw new IllegalStateException("Cannot configure workflow test database", exception); + } + if (externalUrl == null) { + dataSource.setUser(dialect == ConnectionType.MYSQL ? "root" : "postgres"); + dataSource.setPassword(PASSWORD); + } + OpenMetadataApplicationConfig config = new OpenMetadataApplicationConfig(); + config.setDataSourceFactory(dataSource); + return config; + } + + private static String votingProcess() { + return """ + + + + + + + + + + + + + + + + + """; + } + + private static void assertStorageContract(Handle handle, ConnectionType dialect) { + migrate(handle, dialect); + migrate(handle, dialect); + CollectionDAO dao = handle.attach(CollectionDAO.class); + Ontology ontology = ontology(); + OntologyConcept concept = concept(ontology); + OntologyProperty property = property(ontology); + handle.useTransaction( + transaction -> { + dao.ontologyEntityDAO().insert(ontology, ontology.getFullyQualifiedName()); + dao.ontologyConceptDAO().insert(concept, concept.getFullyQualifiedName()); + dao.ontologyPropertyDAO().insert(property, property.getFullyQualifiedName()); + }); + assertEquals(ontology, dao.ontologyEntityDAO().findEntityById(ontology.getId())); + assertEquals( + concept, dao.ontologyConceptDAO().findEntityByName(concept.getFullyQualifiedName())); + assertEquals(property, dao.ontologyPropertyDAO().findEntityById(property.getId())); + assertRenameAndDelete(dao, concept); + assertSemanticIdentity(dao, ontology); + assertUniquenessAndRollback(handle, dao, ontology); + assertRepositoryContract(handle, dialect, dao); + } + + private static void assertSemanticIdentity(CollectionDAO dao, Ontology ontology) { + OntologyConcept duplicateConcept = + concept(ontology).withName("OtherCustomer").withFullyQualifiedName("Sales.OtherCustomer"); + assertThrows( + UnableToExecuteStatementException.class, + () -> + dao.ontologyConceptDAO() + .insert(duplicateConcept, duplicateConcept.getFullyQualifiedName())); + OntologyProperty duplicateProperty = + property(ontology).withName("otherId").withFullyQualifiedName("Sales.otherId"); + assertThrows( + UnableToExecuteStatementException.class, + () -> + dao.ontologyPropertyDAO() + .insert(duplicateProperty, duplicateProperty.getFullyQualifiedName())); + + Ontology other = ontology().withName("Partners").withFullyQualifiedName("Partners"); + dao.ontologyEntityDAO().insert(other, other.getFullyQualifiedName()); + OntologyConcept imported = concept(other).withFullyQualifiedName("Partners.Customer"); + dao.ontologyConceptDAO().insert(imported, imported.getFullyQualifiedName()); + for (String localName : List.of("customer", "客户")) { + OntologyConcept distinct = + concept(ontology) + .withName(localName) + .withFullyQualifiedName("Sales." + localName) + .withIri(NAMESPACE.resolve(localName)); + dao.ontologyConceptDAO().insert(distinct, distinct.getFullyQualifiedName()); + assertEquals( + distinct.getIri(), dao.ontologyConceptDAO().findEntityById(distinct.getId()).getIri()); + } + } + + private static void assertRepositoryContract( + Handle handle, ConnectionType dialect, CollectionDAO dao) { + migrateRepositoryTables(handle, dialect); + CollectionDAO previous = Entity.getCollectionDAO(); + EntityRelationshipRepository previousRelationships = Entity.getEntityRelationshipRepository(); + Entity.setCollectionDAO(handle.getJdbi().onDemand(CollectionDAO.class)); + Entity.setEntityRelationshipRepository( + new EntityRelationshipRepository(Entity.getCollectionDAO())); + try { + OntologyRepository repository = new OntologyRepository(); + Ontology created = + repository.create(null, ontology().withName("GovernedSales").withVersion(0.1)); + assertNull(created.getEntityStatus()); + assertEquals(1L, dao.ontologyStateDAO().get(created.getId()).getWorkingRevision()); + Ontology original = + repository.get(null, created.getId(), repository.getFields("owners,reviewers")); + Ontology edited = + JsonUtils.deepCopy(original, Ontology.class).withDescription("Reviewed definition"); + repository.update(null, original, edited, "admin"); + assertEquals(2L, dao.ontologyStateDAO().get(created.getId()).getWorkingRevision()); + assertThrows( + PreconditionFailedException.class, + () -> + repository.update( + null, + original, + JsonUtils.deepCopy(original, Ontology.class).withDescription("Stale overwrite"), + "admin")); + Ontology current = + repository.get(null, created.getId(), repository.getFields("owners,reviewers")); + assertEquals( + "Reviewed definition", + dao.ontologyEntityDAO().findEntityById(created.getId()).getDescription()); + assertThrows( + IllegalStateException.class, + () -> + repository.executeInTransaction( + () -> { + Ontology updated = + JsonUtils.deepCopy(current, Ontology.class).withDescription("Rolled back"); + repository.update(null, current, updated, "admin"); + throw new IllegalStateException("Roll back the approval transaction"); + })); + assertEquals( + "Reviewed definition", + dao.ontologyEntityDAO().findEntityById(created.getId()).getDescription()); + assertPropertyValidation(created); + assertEquals(2L, dao.ontologyStateDAO().get(created.getId()).getWorkingRevision()); + OntologyConceptRepository concepts = new OntologyConceptRepository(); + assertThrows( + BadRequestException.class, + () -> concepts.validateDefinition(concept(created).withConceptType(null))); + } finally { + Entity.setCollectionDAO(previous); + Entity.setEntityRelationshipRepository(previousRelationships); + } + } + + private static void assertPropertyValidation(Ontology ontology) { + OntologyPropertyRepository repository = new OntologyPropertyRepository(); + OntologyProperty property = + property(ontology) + .withDomain(null) + .withRange(null) + .withDisjointWith(null) + .withPropertyChain(null); + assertDoesNotThrow(() -> repository.validateDefinition(property)); + property.withKind(null); + assertThrows(BadRequestException.class, () -> repository.validateDefinition(property)); + property.withKind(OntologyPropertyKind.DATA).withInverse(NAMESPACE.resolve("inverse")); + assertThrows(BadRequestException.class, () -> repository.validateDefinition(property)); + property + .withKind(OntologyPropertyKind.OBJECT) + .withInverse(null) + .withDomain(List.of(URI.create("relative"))); + assertThrows(BadRequestException.class, () -> repository.validateDefinition(property)); + property.withDomain(null).withKind(OntologyPropertyKind.ANNOTATION); + property.withCharacteristics(Set.of(RelationshipCharacteristic.FUNCTIONAL)); + assertThrows(BadRequestException.class, () -> repository.validateDefinition(property)); + property.withKind(OntologyPropertyKind.DATA); + assertDoesNotThrow(() -> repository.validateDefinition(property)); + property.withCharacteristics(Set.of(RelationshipCharacteristic.TRANSITIVE)); + assertThrows(BadRequestException.class, () -> repository.validateDefinition(property)); + property.withKind(OntologyPropertyKind.OBJECT); + assertDoesNotThrow(() -> repository.validateDefinition(property)); + property.withPropertyChain(List.of(NAMESPACE.resolve("oneLink"))); + assertThrows(BadRequestException.class, () -> repository.validateDefinition(property)); + property.withPropertyChain(List.of(NAMESPACE.resolve("first"), NAMESPACE.resolve("second"))); + assertDoesNotThrow(() -> repository.validateDefinition(property)); + property + .withKind(OntologyPropertyKind.ANNOTATION) + .withPropertyChain(null) + .withCharacteristics(null) + .withDisjointWith(List.of(NAMESPACE.resolve("annotation"))); + assertThrows(BadRequestException.class, () -> repository.validateDefinition(property)); + } + + private static void assertRenameAndDelete(CollectionDAO dao, OntologyConcept concept) { + String previousName = concept.getFullyQualifiedName(); + URI iri = concept.getIri(); + concept.withName("AccountHolder").withFullyQualifiedName("Sales.AccountHolder"); + dao.ontologyConceptDAO().update(concept); + assertThrows( + EntityNotFoundException.class, + () -> dao.ontologyConceptDAO().findEntityByName(previousName)); + assertEquals( + iri, dao.ontologyConceptDAO().findEntityByName(concept.getFullyQualifiedName()).getIri()); + concept.setDeleted(true); + dao.ontologyConceptDAO().update(concept); + assertThrows( + EntityNotFoundException.class, + () -> dao.ontologyConceptDAO().findEntityById(concept.getId())); + assertEquals( + concept, dao.ontologyConceptDAO().findEntityById(concept.getId(), Include.DELETED)); + } + + private static void assertUniquenessAndRollback( + Handle handle, CollectionDAO dao, Ontology ontology) { + Ontology duplicate = ontology(); + assertThrows( + UnableToExecuteStatementException.class, + () -> dao.ontologyEntityDAO().insert(duplicate, duplicate.getFullyQualifiedName())); + OntologyConcept rolledBack = + concept(ontology) + .withName("RolledBack") + .withFullyQualifiedName("Sales.RolledBack") + .withIri(NAMESPACE.resolve("RolledBack")); + assertThrows( + IllegalStateException.class, + () -> + handle.useTransaction( + transaction -> { + dao.ontologyConceptDAO().insert(rolledBack, rolledBack.getFullyQualifiedName()); + throw new IllegalStateException("Rollback ontology write"); + })); + assertThrows( + EntityNotFoundException.class, + () -> dao.ontologyConceptDAO().findEntityById(rolledBack.getId())); + OntologyConcept missingContainer = concept(ontology).withOntology(null); + assertThrows( + UnableToExecuteStatementException.class, + () -> + dao.ontologyConceptDAO() + .insert(missingContainer, missingContainer.getFullyQualifiedName())); + } + + private static Ontology ontology() { + return new Ontology() + .withId(UUID.randomUUID()) + .withName("Sales") + .withFullyQualifiedName("Sales") + .withIri(URI.create("https://example.org/sales")) + .withNamespace(NAMESPACE) + .withUpdatedAt(1L) + .withUpdatedBy("admin"); + } + + private static EntityReference reference(Ontology ontology) { + return new EntityReference() + .withId(ontology.getId()) + .withType("ontology") + .withName(ontology.getName()); + } + + private static OntologyConcept concept(Ontology ontology) { + return new OntologyConcept() + .withId(UUID.randomUUID()) + .withName("Customer") + .withFullyQualifiedName("Sales.Customer") + .withOntology(reference(ontology)) + .withIri(NAMESPACE.resolve("Customer")) + .withConceptType(OntologyConceptType.OWL_CLASS) + .withUpdatedAt(1L) + .withUpdatedBy("admin"); + } + + private static OntologyProperty property(Ontology ontology) { + return new OntologyProperty() + .withId(UUID.randomUUID()) + .withName("customerId") + .withFullyQualifiedName("Sales.customerId") + .withOntology(reference(ontology)) + .withIri(NAMESPACE.resolve("customerId")) + .withKind(OntologyPropertyKind.DATA) + .withUpdatedAt(1L) + .withUpdatedBy("admin"); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyHierarchyStorageTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyHierarchyStorageTest.java new file mode 100644 index 000000000000..9a03b0b2000a --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyHierarchyStorageTest.java @@ -0,0 +1,349 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.openmetadata.service.jdbi3.OntologyCaptureFixture.withCapture; + +import jakarta.json.Json; +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.OntologyRestrictionKind; +import org.openmetadata.schema.type.RelationshipCharacteristic; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.exception.OntologyValidationException; +import org.openmetadata.service.jdbi3.OntologyCaptureFixture.Fixture; +import org.openmetadata.service.jdbi3.locator.ConnectionType; +import org.openmetadata.service.util.EntityUtil; + +class OntologyHierarchyStorageTest { + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void incomingInverseDeclarationsHaveTheSameRulesForPreviewSaveAndCapture(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + final var customer = concept(fixture, "Customer"); + final var party = concept(fixture, "Party"); + final var properties = new OntologyPropertyRepository(); + final var property = properties.create(null, property(fixture, "p")); + final var inverse = + properties.create(null, property(fixture, "q").withInverse(property.getIri())); + properties.create( + null, + property(fixture, "r") + .withInverse(inverse.getIri()) + .withCharacteristics(Set.of(RelationshipCharacteristic.TRANSITIVE))); + final var axioms = new OntologyAxiomRepository(); + final var restriction = + subclass(customer) + .withExpressions( + List.of( + new OntologyExpression() + .withKind(OntologyExpressionKind.RESTRICTION) + .withPropertyIri(property.getIri()) + .withRestrictionKind(OntologyRestrictionKind.EXACT) + .withCardinality(1) + .withFiller( + new OntologyExpression() + .withKind(OntologyExpressionKind.NAMED_CLASS) + .withClassIri(party.getIri())))); + final long revision = + fixture.ontologies.getState(fixture.ontology.getId()).getWorkingRevision(); + assertFalse(axioms.validate(restriction).getValid()); + assertThrows(OntologyValidationException.class, () -> axioms.create(null, restriction)); + assertEquals(0, fixture.rowCount("ontology_axiom_entity")); + assertEquals( + revision, fixture.ontologies.getState(fixture.ontology.getId()).getWorkingRevision()); + assertEquals(6, fixture.capture(ignored -> {}).getMemberCount()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void previewsDoNotWaitForWriters(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + final var customer = concept(fixture, "Customer"); + final var party = concept(fixture, "Party"); + try (var readers = Executors.newSingleThreadExecutor()) { + fixture.handle.begin(); + try { + fixture + .handle + .attach(CollectionDAO.class) + .ontologyStateDAO() + .lock(fixture.ontology.getId()); + final var preview = + readers.submit( + () -> new OntologyAxiomRepository().validate(subclass(customer, party))); + assertTrue(preview.get(3, TimeUnit.SECONDS).getValid()); + } finally { + fixture.handle.rollback(); + } + } catch (InterruptedException | ExecutionException | TimeoutException failure) { + throw new AssertionError(failure); + } + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void unrelatedInvalidDefinitionsDoNotBlockEditingOrRepair(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + final var customer = concept(fixture, "Customer"); + final var party = concept(fixture, "Party"); + final var axioms = new OntologyAxiomRepository(); + final var invalid = subclass(customer, party).withSubjectIri(URI.create("urn:missing")); + axioms.setFullyQualifiedName(invalid); + axioms.getDao().insert(invalid, invalid.getFullyQualifiedName()); + + fixture.concepts.patch( + null, + party.getId(), + "admin", + Json.createPatchBuilder().add("/description", "Updated documentation").build()); + concept(fixture, "Unrelated"); + assertThrows(OntologyValidationException.class, () -> fixture.capture(ignored -> {})); + + axioms.patch( + null, + invalid.getId(), + "admin", + Json.createPatchBuilder() + .replace("/subjectIri", customer.getIri().toString()) + .build()); + assertEquals(5, fixture.capture(ignored -> {}).getMemberCount()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void previewsUseTheWholeWorkingHierarchyWithoutChangingRowsOrRevisions(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + var customer = concept(fixture, "Customer"); + var party = concept(fixture, "Party"); + var axioms = new OntologyAxiomRepository(); + var parents = axioms.create(null, subclass(customer, party)); + var states = fixture.handle.attach(CollectionDAO.class).ontologyStateDAO(); + var revision = states.get(fixture.ontology.getId()).getWorkingRevision(); + assertFalse(axioms.validate(subclass(party, customer)).getValid()); + var unknown = new OntologyConcept().withIri(URI.create("urn:unknown")); + assertFalse(axioms.validate(subclass(customer, unknown)).getValid()); + assertTrue(axioms.validate(parents).getValid()); + assertTrue( + axioms.validate(subclass(party, customer).withName(parents.getName())).getValid()); + assertFalse(axioms.validate(subclass(customer)).getValid()); + assertEquals(1, fixture.rowCount("ontology_axiom_entity")); + assertEquals(revision, states.get(fixture.ontology.getId()).getWorkingRevision()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void hierarchyReadsAreDerivedAndReferencesProtectDeclarations(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + var customer = concept(fixture, "Customer"); + var party = concept(fixture, "Party"); + var legal = concept(fixture, "LegalEntity"); + var axioms = new OntologyAxiomRepository(); + var parents = axioms.create(null, subclass(customer, party, legal)); + var fields = fixture.concepts.getFields("superClasses,subClasses"); + var current = fixture.concepts.get(null, customer.getId(), fields); + assertEquals(List.of(legal.getIri(), party.getIri()), current.getSuperClasses()); + assertEquals( + List.of(customer.getIri()), + fixture.concepts.get(null, party.getId(), fields).getSubClasses()); + assertNull( + fixture + .concepts + .get(null, customer.getId(), fixture.concepts.getFields("")) + .getSuperClasses()); + assertThrows( + OntologyValidationException.class, + () -> axioms.create(null, subclass(party, customer))); + assertThrows( + OntologyValidationException.class, + () -> fixture.concepts.delete("admin", party.getId(), false, false)); + assertThrows( + OntologyValidationException.class, + () -> + fixture.concepts.patch( + null, + party.getId(), + "admin", + Json.createPatchBuilder().replace("/conceptType", "SKOS_CONCEPT").build())); + assertThrows( + BadRequestException.class, + () -> + fixture.concepts.patch( + null, + customer.getId(), + "admin", + Json.createPatchBuilder() + .add("/superClasses", Json.createArrayBuilder().build()) + .build())); + + fixture.concepts.patch( + null, + customer.getId(), + "admin", + Json.createPatchBuilder().add("/description", "A customer").build()); + assertFalse( + fixture + .handle + .attach(CollectionDAO.class) + .ontologyConceptDAO() + .findJsonByIdForUpdate(customer.getId(), Include.ALL) + .contains("superClasses")); + assertFalse( + fixture + .handle + .attach(CollectionDAO.class) + .entityExtensionDAO() + .getExtension( + customer.getId(), + EntityUtil.getVersionExtension(Entity.ONTOLOGY_CONCEPT, 0.1)) + .contains("superClasses")); + axioms.delete("admin", parents.getId(), false, false); + assertEquals( + List.of(), fixture.concepts.get(null, customer.getId(), fields).getSuperClasses()); + fixture.concepts.delete("admin", party.getId(), false, false); + assertThrows( + OntologyValidationException.class, + () -> axioms.restoreEntity("admin", parents.getId())); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void concurrentOppositeEdgesCannotCommitACycleEvenWithAnOlderReadSnapshot( + ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + var first = concept(fixture, "First"); + var second = concept(fixture, "Second"); + var ready = new CountDownLatch(2); + try (var writers = Executors.newFixedThreadPool(2)) { + var left = + writers.submit(() -> insertAfterRead(fixture, subclass(first, second), ready)); + var right = + writers.submit(() -> insertAfterRead(fixture, subclass(second, first), ready)); + assertEquals(1, left.get(30, TimeUnit.SECONDS) + right.get(30, TimeUnit.SECONDS)); + } catch (InterruptedException | ExecutionException | TimeoutException failure) { + throw new AssertionError(failure); + } + assertEquals(1, fixture.rowCount("ontology_axiom_entity")); + }); + } + + private static int insertAfterRead(Fixture fixture, OntologyAxiom axiom, CountDownLatch ready) { + var axioms = new OntologyAxiomRepository(); + try { + return fixture.ontologies.executeInTransaction( + () -> { + axioms.getDao().listCount(new ListFilter(Include.NON_DELETED)); + ready.countDown(); + try { + assertTrue(ready.await(10, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + axioms.create(null, axiom); + return 1; + }); + } catch (OntologyValidationException rejected) { + assertTrue(rejected.getMessage().contains("cycle"), rejected.getMessage()); + return 0; + } + } + + private static OntologyConcept concept(Fixture fixture, String name) { + return fixture.concepts.create( + null, + new OntologyConcept() + .withId(UUID.randomUUID()) + .withName(name) + .withOntology(fixture.ontology.getEntityReference()) + .withIri(URI.create("urn:class:" + name)) + .withConceptType(OntologyConceptType.OWL_CLASS) + .withVersion(0.1) + .withUpdatedAt(1L) + .withUpdatedBy("admin")); + } + + private static OntologyProperty property(Fixture fixture, String name) { + return new OntologyProperty() + .withId(UUID.randomUUID()) + .withName(name) + .withOntology(fixture.ontology.getEntityReference()) + .withKind(OntologyPropertyKind.OBJECT) + .withVersion(0.1) + .withUpdatedAt(1L) + .withUpdatedBy("admin"); + } + + private static OntologyAxiom subclass(OntologyConcept source, OntologyConcept... targets) { + var id = UUID.randomUUID(); + return new OntologyAxiom() + .withId(id) + .withName("Subclass" + id) + .withOntology(source.getOntology()) + .withAxiomType(OntologyAxiomType.SUBCLASS_OF) + .withSubjectIri(source.getIri()) + .withExpressions( + Arrays.stream(targets) + .map( + target -> + new OntologyExpression() + .withKind(OntologyExpressionKind.NAMED_CLASS) + .withClassIri(target.getIri())) + .toList()) + .withVersion(0.1) + .withUpdatedAt(1L) + .withUpdatedBy("admin"); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyListFilterTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyListFilterTest.java new file mode 100644 index 000000000000..1645133092e9 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyListFilterTest.java @@ -0,0 +1,33 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.type.Include; + +class OntologyListFilterTest { + @Test + void bindsTheOwningOntologyForListAndCountQueries() { + String id = "value' OR 1=1 --"; + ListFilter filter = new ListFilter(Include.NON_DELETED).addQueryParam("ontologyId", id); + + assertTrue(filter.getCondition().contains("ontologyId = :ontologyIdParam")); + assertTrue( + filter + .getCondition("ontology_concept_entity") + .contains("ontology_concept_entity.ontologyId = :ontologyIdParam")); + assertTrue( + filter + .getCondition("ontology_property_entity") + .contains("ontology_property_entity.ontologyId = :ontologyIdParam")); + assertTrue( + filter + .getCondition("ontology_release_entity") + .contains("ontology_release_entity.ontologyId = :ontologyIdParam")); + assertFalse(filter.getCondition().contains(id)); + assertEquals(id, filter.getQueryParams().get("ontologyIdParam")); + assertFalse(filter.getCondition("glossary_term_entity").contains("ontologyId")); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReferenceDAOTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReferenceDAOTest.java new file mode 100644 index 000000000000..1c2d587fe3c1 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReferenceDAOTest.java @@ -0,0 +1,116 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.openmetadata.service.jdbi3.OntologyTestDatabase.migrate; +import static org.openmetadata.service.jdbi3.OntologyTestDatabase.withDatabase; + +import java.net.URI; +import java.util.List; +import java.util.UUID; +import org.apache.commons.codec.digest.DigestUtils; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.OntologyReference; +import org.openmetadata.schema.type.OntologyReferenceRole; +import org.openmetadata.service.jdbi3.locator.ConnectionType; + +class OntologyReferenceDAOTest { + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void reverseLookupIsExactAndScopedToTheOwningModule(ConnectionType dialect) { + withDatabase( + dialect, + handle -> { + migrate(handle, dialect); + var dao = handle.attach(CollectionDAO.class).ontologyReferenceDAO(); + UUID ontology = UUID.randomUUID(); + UUID source = UUID.randomUUID(); + List iris = + List.of( + "urn:example:Customer", + "URN:example:Customer", + "urn:example:café", + "urn:example:café"); + for (String iri : iris) { + dao.insert(reference(ontology, source, iri), DigestUtils.sha256Hex(iri)); + } + String selected = iris.getFirst(); + dao.insert( + reference(UUID.randomUUID(), UUID.randomUUID(), selected), + DigestUtils.sha256Hex(selected)); + // A hash match must still compare the full IRI. + dao.insert( + reference(ontology, UUID.randomUUID(), "urn:wrong"), DigestUtils.sha256Hex(selected)); + for (String iri : iris) { + var matches = dao.list(ontology, DigestUtils.sha256Hex(iri), iri, "", "", "", 10); + assertEquals(1, matches.size()); + assertEquals(iri, matches.getFirst().getIri().toString()); + assertEquals(source, matches.getFirst().getSource().getId()); + assertEquals(1, dao.count(ontology, DigestUtils.sha256Hex(iri), iri)); + } + migrate(handle, dialect); + assertEquals(1, dao.count(ontology, DigestUtils.sha256Hex(selected), selected)); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void referencesHaveStablePagingAndShareTheirWritersTransaction(ConnectionType dialect) { + withDatabase( + dialect, + handle -> { + migrate(handle, dialect); + var dao = handle.attach(CollectionDAO.class).ontologyReferenceDAO(); + UUID ontology = UUID.randomUUID(); + UUID source = UUID.randomUUID(); + String iri = "urn:target"; + String hash = DigestUtils.sha256Hex(iri); + OntologyReference domain = + reference(ontology, source, iri).withRole(OntologyReferenceRole.DOMAIN); + OntologyReference range = + reference(ontology, source, iri).withRole(OntologyReferenceRole.RANGE); + dao.insert(range, hash); + dao.insert(domain, hash); + var first = dao.list(ontology, hash, iri, "", "", "", 1).getFirst(); + assertEquals(OntologyReferenceRole.DOMAIN, first.getRole()); + var second = + dao.list( + ontology, + hash, + iri, + first.getSource().getType(), + first.getSource().getId().toString(), + first.getRole().value(), + 1) + .getFirst(); + assertEquals(OntologyReferenceRole.RANGE, second.getRole()); + assertThrows( + IllegalStateException.class, + () -> + handle.useTransaction( + ignored -> { + dao.delete("ontologyProperty", source); + throw new IllegalStateException("Rollback definition write"); + })); + assertEquals(2, dao.count(ontology, hash, iri)); + dao.delete("ontologyConcept", source); + assertEquals(2, dao.count(ontology, hash, iri)); + dao.delete("ontologyProperty", source); + assertEquals(0, dao.count(ontology, hash, iri)); + }); + } + + private static OntologyReference reference(UUID ontology, UUID source, String iri) { + return new OntologyReference() + .withOntologyId(ontology) + .withSource(new EntityReference().withType("ontologyProperty").withId(source)) + .withRole(OntologyReferenceRole.DOMAIN) + .withIri(URI.create(iri)); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseActivationTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseActivationTest.java new file mode 100644 index 000000000000..8a0f944018d7 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseActivationTest.java @@ -0,0 +1,115 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.openmetadata.service.jdbi3.OntologyCaptureFixture.withCapture; + +import jakarta.json.Json; +import java.time.Clock; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.openmetadata.schema.api.data.ActivateOntologyRelease; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.service.jdbi3.locator.ConnectionType; +import org.openmetadata.service.ontology.OntologyReleaseActivation; + +class OntologyReleaseActivationTest { + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void immutableContentChecksDoNotHoldTheOntologyWriteLock(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + final var release = fixture.capture(ignored -> {}); + fixture + .releases + .getDaoCollection() + .ontologyReleaseDAO() + .update(release.withEntityStatus(EntityStatus.APPROVED)); + final var activation = + new OntologyReleaseActivation(fixture.ontologies, Clock.systemUTC(), ignored -> {}); + final var access = + new OntologyReleaseActivation.Access( + "admin", + definitions -> { + if (definitions.stream().anyMatch(OntologyRelease.class::isInstance)) { + try (final var writer = Executors.newSingleThreadExecutor()) { + writer + .submit( + () -> + fixture.ontologies.patch( + null, + fixture.ontology.getId(), + "admin", + Json.createPatchBuilder() + .add( + "/description", + "Edited while validating the release") + .build())) + .get(15, TimeUnit.SECONDS); + } catch (InterruptedException + | ExecutionException + | TimeoutException failure) { + throw new AssertionError(failure); + } + } + }); + final var active = + activation.activate( + fixture.ontology.getId(), + new ActivateOntologyRelease() + .withReleaseId(release.getId()) + .withExpectedActivationRevision(0L), + access); + assertEquals(release.getId(), active.getActiveReleaseId()); + assertEquals(2L, active.getWorkingRevision()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void projectionEnqueueFailureRollsBackThePointerAndAuditEvent(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + final var release = fixture.capture(ignored -> {}); + final var dao = fixture.releases.getDaoCollection().ontologyReleaseDAO(); + dao.update(release.withEntityStatus(EntityStatus.APPROVED)); + final var request = + new ActivateOntologyRelease() + .withReleaseId(release.getId()) + .withExpectedActivationRevision(0L); + final var access = new OntologyReleaseActivation.Access("admin", ignored -> {}); + final var unavailable = + new OntologyReleaseActivation( + fixture.ontologies, + Clock.systemUTC(), + ignored -> { + throw new IllegalStateException("outbox unavailable"); + }); + assertThrows( + IllegalStateException.class, + () -> unavailable.activate(fixture.ontology.getId(), request, access)); + final var unchanged = fixture.ontologies.getState(fixture.ontology.getId()); + assertNull(unchanged.getActiveReleaseId()); + assertEquals(0L, unchanged.getActivationRevision()); + assertEquals(1, dao.countEvents(release.getId())); + final var ready = + new OntologyReleaseActivation(fixture.ontologies, Clock.systemUTC(), ignored -> {}); + assertEquals( + release.getId(), + ready.activate(fixture.ontology.getId(), request, access).getActiveReleaseId()); + assertEquals(2, dao.countEvents(release.getId())); + }); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseCaptureTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseCaptureTest.java new file mode 100644 index 000000000000..3b498fd15fd2 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseCaptureTest.java @@ -0,0 +1,292 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.openmetadata.service.jdbi3.OntologyCaptureFixture.withCapture; + +import jakarta.ws.rs.ForbiddenException; +import java.net.URI; +import java.util.List; +import java.util.UUID; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.riot.Lang; +import org.apache.jena.riot.RDFParser; +import org.apache.jena.vocabulary.OWL2; +import org.apache.jena.vocabulary.RDF; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.openmetadata.schema.api.data.ConceptMapping; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyProfileViolationCode; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.OntologyValidationException; +import org.openmetadata.service.exception.TaskStateConflictException; +import org.openmetadata.service.jdbi3.locator.ConnectionType; +import org.openmetadata.service.rdf.OntologyReleaseExporter; +import org.openmetadata.service.rdf.RdfSerializationFormat; + +class OntologyReleaseCaptureTest { + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void decidedReleasesCannotBeSubmittedForAnotherReview(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + final var release = fixture.capture(ignored -> {}); + for (final var status : List.of(EntityStatus.APPROVED, EntityStatus.REJECTED)) { + fixture.releases.getDao().update(release.withEntityStatus(status)); + final var task = + new Task().withId(UUID.randomUUID()).withAbout(release.getEntityReference()); + assertThrows( + TaskStateConflictException.class, () -> fixture.releases.prepareReview(task)); + assertEquals( + status, + fixture.releases.getDao().findEntityById(release.getId()).getEntityStatus()); + } + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void invalidStoredContentCannotAllocateAReleaseAndReportsTheFailingMember( + ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + final var invalid = + new OntologyAxiom() + .withId(UUID.randomUUID()) + .withName("Invalid") + .withOntology(fixture.ontology.getEntityReference()) + .withVersion(0.1) + .withUpdatedAt(1L) + .withUpdatedBy("admin") + .withSubjectIri(URI.create("urn:undeclared")) + .withAxiomType(OntologyAxiomType.SUBCLASS_OF) + .withExpressions( + List.of( + new OntologyExpression() + .withKind(OntologyExpressionKind.NAMED_CLASS) + .withClassIri(URI.create(OWL2.Thing.getURI())))); + final var axioms = new OntologyAxiomRepository(); + axioms.setFullyQualifiedName(invalid); + axioms.getDao().insert(invalid, invalid.getFullyQualifiedName()); + final var failure = + assertThrows( + OntologyValidationException.class, () -> fixture.capture(reference -> {})); + final var issue = failure.getError().getViolations().getFirst(); + assertEquals(invalid.getId(), issue.getMemberId()); + assertEquals(MemberKind.AXIOM, issue.getMemberKind()); + assertEquals( + OntologyProfileViolationCode.UNDECLARED_CLASS, issue.getViolation().getCode()); + assertEquals(0, fixture.rowCount("ontology_release_entity")); + assertEquals(0, fixture.rowCount("ontology_release_event")); + assertEquals( + 0L, fixture.ontologies.getState(fixture.ontology.getId()).getLastReleaseRevision()); + fixture + .handle + .createUpdate("DELETE FROM ontology_axiom_entity WHERE id = :id") + .bind("id", invalid.getId().toString()) + .execute(); + assertEquals( + OntologyRelease.LogicalCheck.NOT_RUN, + fixture.capture(reference -> {}).getLogicalCheck()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void referencesCommitAndRollBackWithTheCanonicalDefinition(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + OntologyConcept concept = + new OntologyConcept() + .withId(UUID.randomUUID()) + .withName("Mapped") + .withOntology(fixture.ontology.getEntityReference()) + .withConceptType(OntologyConceptType.BOTH) + .withVersion(0.1) + .withUpdatedAt(1L) + .withUpdatedBy("admin") + .withConceptMappings( + List.of( + new ConceptMapping() + .withConceptIri(URI.create("urn:before")) + .withMappingType(ConceptMapping.ConceptMappingType.EXACT_MATCH))); + OntologyConcept original = fixture.concepts.create(null, concept); + assertEquals(1, fixture.rowCount("ontology_reference")); + OntologyConcept updated = + JsonUtils.deepCopy(original, OntologyConcept.class) + .withConceptMappings( + List.of( + new ConceptMapping() + .withConceptIri(URI.create("urn:after")) + .withMappingType(ConceptMapping.ConceptMappingType.EXACT_MATCH))); + assertThrows( + IllegalStateException.class, + () -> + fixture.concepts.executeInTransaction( + () -> { + fixture.concepts.update(null, original, updated, "admin"); + throw new IllegalStateException( + "Roll back the definition and its references"); + })); + assertEquals( + "urn:before", + fixture + .handle + .createQuery("SELECT iri FROM ontology_reference WHERE sourceId = :id") + .bind("id", original.getId().toString()) + .mapTo(String.class) + .one()); + fixture.concepts.update(null, original, updated, "admin"); + assertEquals( + "urn:after", + fixture + .handle + .createQuery("SELECT iri FROM ontology_reference WHERE sourceId = :id") + .bind("id", original.getId().toString()) + .mapTo(String.class) + .one()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void readingAnUninitializedStateDoesNotWriteUntilCapture(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + fixture + .handle + .createUpdate("DELETE FROM ontology_state WHERE ontologyId = :id") + .bind("id", fixture.ontology.getId().toString()) + .execute(); + var state = fixture.ontologies.getState(fixture.ontology.getId()); + assertEquals(fixture.ontology.getId(), state.getOntologyId()); + assertEquals(0L, state.getWorkingRevision()); + assertEquals(0L, state.getLastReleaseRevision()); + assertEquals(0L, state.getActivationRevision()); + assertNull(state.getActiveReleaseId()); + assertNull( + fixture + .ontologies + .getDaoCollection() + .ontologyStateDAO() + .get(fixture.ontology.getId())); + OntologyRelease release = fixture.capture(reference -> {}); + assertEquals(0L, release.getSourceWorkingRevision()); + assertEquals( + 1L, fixture.ontologies.getState(fixture.ontology.getId()).getLastReleaseRevision()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void rollbackRemovesMembersEventsAndReleaseAllocation(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + assertThrows( + IllegalStateException.class, + () -> + fixture.ontologies.executeInTransaction( + () -> { + fixture.capture(reference -> {}); + throw new IllegalStateException("Roll back sealed capture"); + })); + assertEquals(0, fixture.rowCount("ontology_release_entity")); + assertEquals(0, fixture.rowCount("ontology_release_member")); + assertEquals(0, fixture.rowCount("ontology_release_event")); + assertEquals( + 0L, fixture.ontologies.getState(fixture.ontology.getId()).getLastReleaseRevision()); + OntologyRelease release = fixture.capture(reference -> {}); + assertEquals(1L, release.getReleaseRevision()); + assertEquals(1, fixture.rowCount("ontology_release_event")); + assertNull(fixture.ontologies.getState(fixture.ontology.getId()).getActiveReleaseId()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void aDeniedMemberAbortsTheWholeCapture(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + fixture.addConcepts(1); + assertThrows( + ForbiddenException.class, + () -> + fixture.capture( + reference -> { + if (Entity.ONTOLOGY_CONCEPT.equals(reference.getType())) { + throw new ForbiddenException("Restricted concept"); + } + })); + assertEquals(0, fixture.rowCount("ontology_release_entity")); + assertEquals(0, fixture.rowCount("ontology_release_event")); + assertEquals( + 0L, fixture.ontologies.getState(fixture.ontology.getId()).getLastReleaseRevision()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void captureIncludesAllPagesAndReusesContentDigests(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + fixture.addConcepts(101); + OntologyRelease first = fixture.capture(reference -> {}); + OntologyRelease second = fixture.capture(reference -> {}); + assertEquals(102, first.getMemberCount()); + assertEquals( + 101, + fixture + .releases + .listMembers(first.getId(), MemberKind.CONCEPT, 200, null) + .getData() + .size()); + assertEquals(first.getContentDigest(), second.getContentDigest()); + assertEquals(first.getSourceWorkingRevision(), second.getSourceWorkingRevision()); + assertEquals(2L, second.getReleaseRevision()); + String exported = + new OntologyReleaseExporter(fixture.releases) + .export(first, RdfSerializationFormat.N_TRIPLES, member -> {}); + var model = ModelFactory.createDefaultModel(); + try { + RDFParser.fromString(exported, Lang.NTRIPLES).parse(model); + assertEquals( + 101, model.listResourcesWithProperty(RDF.type, OWL2.Class).toList().size()); + } finally { + model.close(); + } + }); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseComparisonStorageTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseComparisonStorageTest.java new file mode 100644 index 000000000000..29f6419dfe7b --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseComparisonStorageTest.java @@ -0,0 +1,95 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.openmetadata.service.jdbi3.OntologyCaptureFixture.withCapture; + +import jakarta.ws.rs.ForbiddenException; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyReleaseChangeType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.jdbi3.locator.ConnectionType; +import org.openmetadata.service.ontology.OntologyReleaseComparison; + +class OntologyReleaseComparisonStorageTest { + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void changesRetainBothSnapshotsAndTheirPermissions(ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + final var root = fixture.ontology; + final var concept = + fixture.concepts.create( + null, + new OntologyConcept() + .withId(UUID.randomUUID()) + .withName("Customer") + .withDescription("Original meaning") + .withOntology(root.getEntityReference()) + .withConceptType(OntologyConceptType.OWL_CLASS) + .withVersion(0.1) + .withUpdatedAt(1L) + .withUpdatedBy("admin")); + final var previous = fixture.capture(ignored -> {}); + fixture + .releases + .getDao() + .update( + previous.withEntityStatus(org.openmetadata.schema.type.EntityStatus.APPROVED)); + new org.openmetadata.service.ontology.OntologyReleaseActivation( + fixture.ontologies, java.time.Clock.systemUTC(), ignored -> {}) + .activate( + root.getId(), + new org.openmetadata.schema.api.data.ActivateOntologyRelease() + .withReleaseId(previous.getId()) + .withExpectedActivationRevision(0L), + new org.openmetadata.service.ontology.OntologyReleaseActivation.Access( + "admin", ignored -> {})); + + fixture.concepts.update( + null, + concept, + JsonUtils.deepCopy(concept, OntologyConcept.class).withDescription("Revised meaning"), + "admin"); + final var proposed = fixture.capture(ignored -> {}); + assertEquals(previous.getId(), proposed.getBaseReleaseId()); + fixture.releases.getDaoCollection().ontologyConceptDAO().delete(concept.getId()); + + final var comparison = new OntologyReleaseComparison(fixture.releases); + final var result = comparison.compare(proposed, previous, 1, null, ignored -> {}); + assertEquals(1, result.getPaging().getTotal()); + final var change = result.getData().getFirst(); + assertEquals(concept.getId(), change.getEntity().getId()); + assertEquals(root.getId(), change.getOntologyId()); + assertEquals(OntologyReleaseChangeType.CHANGED, change.getChangeType()); + assertEquals(previous.getId(), change.getBeforeReleaseId()); + assertEquals(proposed.getId(), change.getAfterReleaseId()); + + for (final String restricted : List.of("Original meaning", "Revised meaning")) { + assertThrows( + ForbiddenException.class, + () -> + comparison.compare( + proposed, + previous, + 1, + null, + definitions -> { + if (definitions.stream() + .anyMatch( + definition -> restricted.equals(definition.getDescription()))) { + throw new ForbiddenException("Restricted definition"); + } + })); + } + }); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseDAOTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseDAOTest.java new file mode 100644 index 000000000000..e0019c462b95 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseDAOTest.java @@ -0,0 +1,313 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.net.URI; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; +import org.jdbi.v3.core.Handle; +import org.jdbi.v3.core.statement.UnableToExecuteStatementException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.type.OntologyState; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.exception.EntityNotFoundException; +import org.openmetadata.service.jdbi3.locator.ConnectionType; +import org.openmetadata.service.ontology.OntologyReleaseManifest; + +class OntologyReleaseDAOTest { + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void retentionChecksIncludeReleasesCommittedAfterTheTransactionSnapshot(ConnectionType dialect) { + withStorage( + dialect, + storage -> { + storage.handle().begin(); + try { + assertEquals( + 0, + storage + .handle() + .createQuery("SELECT COUNT(*) FROM ontology_release_entity") + .mapTo(Integer.class) + .one()); + try (final Handle capture = storage.handle().getJdbi().open()) { + capture.useTransaction( + transaction -> { + final var writer = + new Storage( + transaction, + transaction.attach(CollectionDAO.class), + storage.ontologyId()); + writer.states().lock(storage.ontologyId()); + writer.insert(writer.release(1)); + }); + } + storage.states().lock(storage.ontologyId()); + assertTrue(storage.releases().hasReleases(storage.ontologyId())); + } finally { + storage.handle().rollback(); + } + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void onlySealedMembersAreReadableAndTheirMembershipIsImmutable(ConnectionType dialect) { + withStorage( + dialect, + storage -> { + OntologyRelease release = storage.release(1); + OntologyReleaseMember member = member(release); + assertFalse(storage.releases().hasReleases(storage.ontologyId())); + storage.insert(release); + assertTrue(storage.releases().hasReleases(storage.ontologyId())); + assertFalse(storage.releases().hasReleases(UUID.randomUUID())); + assertEquals(1, storage.releases().insertMember(member)); + assertThrows( + UnableToExecuteStatementException.class, + () -> storage.releases().insertMember(member)); + assertNull( + storage.releases().getMember(release.getId(), "CONCEPT", member.getMemberId())); + assertTrue(storage.releases().listMembers(release.getId(), "CONCEPT", "", 10).isEmpty()); + sealHeader(release, member); + assertEquals( + 0, storage.releases().seal(release.getId(), JsonUtils.pojoToJson(release), 2)); + assertEquals( + 1, storage.releases().seal(release.getId(), JsonUtils.pojoToJson(release), 1)); + assertEquals(0, storage.releases().insertMember(member(release))); + assertEquals( + 0, storage.releases().seal(release.getId(), JsonUtils.pojoToJson(release), 1)); + List captured = + storage.releases().listMembers(release.getId(), "CONCEPT", "", 10); + assertEquals(release.getContentDigest(), OntologyReleaseManifest.digest(captured)); + assertEquals( + "HTTPS://EXAMPLE.org/客户", captured.getFirst().getPayload().path("iri").asText()); + assertEquals(0.2, captured.getFirst().getSourceVersion()); + assertTrue( + storage + .releases() + .listMembers(release.getId(), "CONCEPT", member.getMemberId().toString(), 10) + .isEmpty()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void activationRequiresAnApprovedOwnedReleaseAndNeverReusesARevision(ConnectionType dialect) { + withStorage( + dialect, + storage -> { + OntologyRelease first = storage.release(1); + OntologyReleaseMember firstMember = member(first); + storage.insert(first); + assertEquals(0, storage.states().activate(storage.ontologyId(), first.getId(), 0)); + storage.releases().insertMember(firstMember); + sealHeader(first, firstMember); + storage.releases().seal(first.getId(), JsonUtils.pojoToJson(first), 1); + assertEquals(0, storage.states().activate(storage.ontologyId(), first.getId(), 0)); + storage.releases().update(first.withEntityStatus(EntityStatus.APPROVED)); + assertEquals(1, storage.states().activate(storage.ontologyId(), first.getId(), 0)); + assertEquals(0, storage.states().activate(storage.ontologyId(), first.getId(), 0)); + + UUID otherOntology = UUID.randomUUID(); + storage.states().initialize(otherOntology); + assertEquals(0, storage.states().activate(otherOntology, first.getId(), 0)); + OntologyRelease second = storage.release(2); + OntologyReleaseMember secondMember = member(second); + storage.insert(second); + storage.releases().insertMember(secondMember); + sealHeader(second, secondMember); + second.setEntityStatus(EntityStatus.APPROVED); + storage.releases().seal(second.getId(), JsonUtils.pojoToJson(second), 1); + assertEquals(1, storage.states().activate(storage.ontologyId(), second.getId(), 1)); + assertEquals(1, storage.states().activate(storage.ontologyId(), first.getId(), 2)); + assertEquals(0, storage.states().activate(storage.ontologyId(), second.getId(), 1)); + OntologyState state = storage.states().get(storage.ontologyId()); + assertEquals(first.getId(), state.getActiveReleaseId()); + assertEquals(3L, state.getActivationRevision()); + storage.releases().update(second.withDeleted(true)); + assertEquals(0, storage.states().activate(storage.ontologyId(), second.getId(), 3)); + assertTrue(storage.releases().hasReleases(storage.ontologyId())); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void memberPayloadsPreserveDecimalPrecision(ConnectionType dialect) { + withStorage( + dialect, + storage -> { + OntologyRelease release = storage.release(1); + OntologyReleaseMember member = + OntologyReleaseManifest.member( + release.getId(), + MemberKind.AXIOM, + UUID.randomUUID(), + JsonUtils.getObjectNode() + .put("value", new BigDecimal("0.12345678901234567890123456789"))); + storage.insert(release); + storage.releases().insertMember(member); + sealHeader(release, member); + storage.releases().seal(release.getId(), JsonUtils.pojoToJson(release), 1); + OntologyReleaseMember stored = + storage.releases().getMember(release.getId(), "AXIOM", member.getMemberId()); + assertEquals(member.getPayload(), stored.getPayload()); + assertEquals(release.getContentDigest(), OntologyReleaseManifest.digest(List.of(stored))); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void candidateContentCountersAndAuditEventsRollBackTogether(ConnectionType dialect) { + withStorage( + dialect, + storage -> { + OntologyRelease release = storage.release(1); + OntologyReleaseMember member = member(release); + assertThrows( + IllegalStateException.class, + () -> + storage + .dao() + .useTransaction( + transaction -> { + assertEquals( + 0L, + storage.states().lock(storage.ontologyId()).getWorkingRevision()); + assertEquals( + 1, storage.states().advanceWorkingRevision(storage.ontologyId())); + assertEquals( + 1, + storage.states().allocateReleaseRevision(storage.ontologyId(), 0)); + assertEquals( + 0, + storage.states().allocateReleaseRevision(storage.ontologyId(), 0)); + storage.insert(release); + storage.releases().insertMember(member); + sealHeader(release, member); + storage + .releases() + .seal(release.getId(), JsonUtils.pojoToJson(release), 1); + storage + .releases() + .appendEvent( + UUID.randomUUID(), + storage.ontologyId(), + release.getId(), + "CAPTURED", + 2, + "author", + "{\"sourceWorkingRevision\":1}"); + throw new IllegalStateException("Roll back candidate capture"); + })); + OntologyState state = storage.states().get(storage.ontologyId()); + assertEquals(0L, state.getWorkingRevision()); + assertEquals(0L, state.getLastReleaseRevision()); + assertNull(state.getActiveReleaseId()); + assertThrows( + EntityNotFoundException.class, + () -> storage.releases().findEntityById(release.getId())); + assertEquals( + 0, + storage + .handle() + .createQuery("SELECT COUNT(*) FROM ontology_release_member WHERE releaseId = :id") + .bind("id", release.getId().toString()) + .mapTo(Integer.class) + .one()); + assertEquals( + 0, + storage + .handle() + .createQuery("SELECT COUNT(*) FROM ontology_release_event WHERE releaseId = :id") + .bind("id", release.getId().toString()) + .mapTo(Integer.class) + .one()); + }); + } + + private static void sealHeader(OntologyRelease release, OntologyReleaseMember member) { + release + .withContentDigest(OntologyReleaseManifest.digest(List.of(member))) + .withMemberCount(1) + .withTotalBytes(member.getPayloadBytes()) + .withSealedAt(2L); + } + + private static OntologyReleaseMember member(OntologyRelease release) { + return OntologyReleaseManifest.member( + release.getId(), + MemberKind.CONCEPT, + UUID.randomUUID(), + JsonUtils.readTree( + "{\"iri\":\"HTTPS://EXAMPLE.org/客户\",\"version\":0.2,\"reviewRevision\":3}")); + } + + private static void withStorage(ConnectionType dialect, Consumer assertion) { + OntologyTestDatabase.withDatabase( + dialect, + handle -> { + OntologyTestDatabase.migrate(handle, dialect); + OntologyTestDatabase.migrate(handle, dialect); + Storage storage = + new Storage(handle, handle.attach(CollectionDAO.class), UUID.randomUUID()); + storage.states().initialize(storage.ontologyId()); + storage.states().initialize(storage.ontologyId()); + assertion.accept(storage); + }); + } + + private record Storage(Handle handle, CollectionDAO dao, UUID ontologyId) { + OntologyStateDAO states() { + return dao.ontologyStateDAO(); + } + + OntologyReleaseDAO releases() { + return dao.ontologyReleaseDAO(); + } + + void insert(OntologyRelease release) { + releases().insert(release, release.getFullyQualifiedName()); + } + + OntologyRelease release(long revision) { + return new OntologyRelease() + .withId(UUID.randomUUID()) + .withName("release" + revision) + .withFullyQualifiedName("Sales." + ontologyId + ".release" + revision) + .withOntology(new EntityReference().withId(ontologyId).withType("ontology")) + .withReleaseRevision(revision) + .withSourceWorkingRevision(0L) + .withOntologyIri(URI.create("https://example.org/sales")) + .withVersionIri(URI.create("https://example.org/sales/release/" + revision)) + .withEncodingVersion( + OntologyRelease.EncodingVersion.fromValue(OntologyReleaseManifest.ENCODING_VERSION)) + .withEntityStatus(EntityStatus.DRAFT) + .withUpdatedAt(1L) + .withUpdatedBy("author") + .withSealedBy("author"); + } + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyTestDatabase.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyTestDatabase.java new file mode 100644 index 000000000000..f3570e52aac0 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyTestDatabase.java @@ -0,0 +1,184 @@ +/* + * Copyright 2026 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.jdbi3; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; +import org.jdbi.v3.core.Handle; +import org.jdbi.v3.core.Jdbi; +import org.jdbi.v3.core.statement.SqlStatements; +import org.jdbi.v3.sqlobject.SqlObjectPlugin; +import org.jdbi.v3.sqlobject.SqlObjects; +import org.openmetadata.service.jdbi3.locator.ConnectionAwareAnnotationSqlLocator; +import org.openmetadata.service.jdbi3.locator.ConnectionType; +import org.openmetadata.service.migration.utils.MigrationFile; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +final class OntologyTestDatabase { + private static final String DATABASE = "ontology_entities"; + static final String PASSWORD = "ontology-storage-test"; + + private OntologyTestDatabase() {} + + static void withDatabase(ConnectionType dialect, Consumer assertion) { + String externalUrl = + System.getProperty("ontology.test." + dialect.name().toLowerCase(Locale.ROOT) + ".url"); + if (externalUrl != null) { + try (Handle handle = openHandle(Jdbi.create(externalUrl), dialect)) { + assertion.accept(handle); + } + return; + } + try (GenericContainer database = database(dialect)) { + database.start(); + try (Handle handle = connect(database, dialect)) { + assertion.accept(handle); + } + } + } + + private static GenericContainer database(ConnectionType dialect) { + return dialect == ConnectionType.MYSQL + ? new GenericContainer<>("mysql:8.0") + .withEnv("MYSQL_DATABASE", DATABASE) + .withEnv("MYSQL_ROOT_PASSWORD", PASSWORD) + .withEnv("MYSQL_ROOT_HOST", "%") + .withExposedPorts(3306) + .waitingFor(Wait.forLogMessage(".*ready for connections.*port: 3306.*\\n", 1)) + : new GenericContainer<>("postgres:15") + .withEnv("POSTGRES_DB", DATABASE) + .withEnv("POSTGRES_PASSWORD", PASSWORD) + .withExposedPorts(5432) + .waitingFor( + Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2)); + } + + private static Handle connect(GenericContainer database, ConnectionType dialect) { + boolean mysql = dialect == ConnectionType.MYSQL; + String url = + "jdbc:%s://%s:%d/%s%s" + .formatted( + mysql ? "mysql" : "postgresql", + database.getHost(), + database.getMappedPort(mysql ? 3306 : 5432), + DATABASE, + mysql + ? "?allowPublicKeyRetrieval=true&useSSL=false&connectTimeout=5000&socketTimeout=30000" + : ""); + return openHandle(Jdbi.create(url, mysql ? "root" : "postgres", PASSWORD), dialect); + } + + private static Handle openHandle(Jdbi jdbi, ConnectionType dialect) { + jdbi.installPlugin(new SqlObjectPlugin()); + jdbi.getConfig(SqlStatements.class).setUnusedBindingAllowed(true); + jdbi.getConfig(SqlObjects.class) + .setSqlLocator(new ConnectionAwareAnnotationSqlLocator(dialect.label)); + return jdbi.open(); + } + + static void migrateRepositoryTables(Handle handle, ConnectionType dialect) { + Path sqlDirectory = + repositoryRoot().resolve("bootstrap/sql/migrations/flyway").resolve(dialect.label); + for (String migration : + List.of("v001__create_db_connection_info.sql", "v004__create_db_connection_info.sql")) { + MigrationFile.parseSQLFile(sqlDirectory.resolve(migration).toFile(), dialect).stream() + .filter( + statement -> + statement.matches( + "(?s).*CREATE TABLE IF NOT EXISTS (entity_relationship|entity_extension|openmetadata_settings|tag_usage|tag|glossary_term_entity|change_event) \\(.*")) + .forEach(handle::execute); + } + for (String version : List.of("1.6.0", "2.0.0", "2.1.0")) { + Path migration = + repositoryRoot() + .resolve("bootstrap/sql/migrations/native") + .resolve(version) + .resolve(dialect == ConnectionType.MYSQL ? "mysql" : "postgres") + .resolve("schemaChanges.sql"); + MigrationFile.parseSQLFile(migration.toFile(), dialect).stream() + .filter( + statement -> + statement.matches( + "(?s).*CREATE TABLE IF NOT EXISTS (workflow_definition_entity|task_entity|custom_property_reference|ontology_axiom_entity|ontology_change_set_entity|change_request) \\(.*")) + .forEach(handle::execute); + } + migrateTableColumns(handle, dialect, "1.13.0", "entity_relationship", "relationType"); + migrateTableColumns(handle, dialect, "1.1.0", "tag_usage", "tagFQNHash"); + migrateTableColumns(handle, dialect, "1.1.0", "tag", "fqnHash"); + migrateTableColumns(handle, dialect, "1.1.0", "glossary_term_entity", "fqnHash"); + migrateTableColumns(handle, dialect, "1.10.0", "tag_usage", "reason"); + migrateTableColumns(handle, dialect, "1.11.0", "tag_usage", "targetfqnhash_lower"); + migrateTableColumns(handle, dialect, "1.11.5", "tag_usage", "appliedAt"); + migrateTableColumns(handle, dialect, "1.12.0", "tag_usage", "metadata"); + } + + private static void migrateTableColumns( + Handle handle, ConnectionType dialect, String version, String table, String column) { + int existing = + handle + .createQuery( + "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = :table" + + " AND lower(column_name) = :column AND table_schema = " + + (dialect == ConnectionType.MYSQL ? "DATABASE()" : "current_schema()")) + .bind("table", table) + .bind("column", column.toLowerCase(Locale.ROOT)) + .mapTo(Integer.class) + .one(); + if (existing == 0) { + Path migration = + repositoryRoot() + .resolve("bootstrap/sql/migrations/native") + .resolve(version) + .resolve(dialect == ConnectionType.MYSQL ? "mysql" : "postgres") + .resolve("schemaChanges.sql"); + MigrationFile.parseSQLFile(migration.toFile(), dialect).stream() + .filter(statement -> statement.matches("(?s).*ALTER TABLE " + table + "\\s.*")) + .forEach(handle::execute); + if (table.equals("tag_usage") && version.equals("1.1.0")) { + MigrationFile.parseSQLFile( + migration.resolveSibling("postDataMigrationSQLScript.sql").toFile(), dialect) + .stream() + .filter(statement -> statement.matches("(?s).*ALTER TABLE tag_usage\\s.*")) + .forEach(handle::execute); + } + } + } + + static void migrate(Handle handle, ConnectionType dialect) { + migrateRepositoryTables(handle, dialect); + Path migration = + repositoryRoot() + .resolve("bootstrap/sql/migrations/native/2.1.0") + .resolve(dialect == ConnectionType.MYSQL ? "mysql" : "postgres") + .resolve("schemaChanges.sql"); + MigrationFile.parseSQLFile(migration.toFile(), dialect).stream() + .dropWhile(statement -> !statement.contains("CREATE TABLE IF NOT EXISTS ontology_entity (")) + .forEach(handle::execute); + } + + static Path repositoryRoot() { + Path root = Path.of("").toAbsolutePath(); + while (root != null && !Files.isDirectory(root.resolve("bootstrap/sql"))) { + root = root.getParent(); + } + if (root == null) { + throw new IllegalStateException("Cannot locate SQL migrations"); + } + return root; + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyWorkingStateTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyWorkingStateTest.java new file mode 100644 index 000000000000..3cca9f534a5b --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyWorkingStateTest.java @@ -0,0 +1,183 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.openmetadata.service.jdbi3.OntologyTestDatabase.migrate; +import static org.openmetadata.service.jdbi3.OntologyTestDatabase.withDatabase; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.exception.PreconditionFailedException; +import org.openmetadata.service.jdbi3.locator.ConnectionType; + +class OntologyWorkingStateTest { + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void creatingDifferentOntologiesDoesNotLockTheSameMissingKeyRange(ConnectionType dialect) { + withDatabase( + dialect, + handle -> { + migrate(handle, dialect); + final var ready = new CountDownLatch(2); + assertDoesNotThrow( + () -> { + try (var writers = Executors.newFixedThreadPool(2)) { + final List> results = new ArrayList<>(); + for (int index = 0; index < 2; index++) { + results.add( + writers.submit( + () -> { + try (var connection = handle.getJdbi().open()) { + final var dao = connection.attach(CollectionDAO.class); + final var ontology = definition(); + dao.inTransaction( + transaction -> + new OntologyWorkingState(transaction) + .mutate( + transaction.ontologyEntityDAO(), + List.of(ontology), + () -> { + ready.countDown(); + assertDoesNotThrow( + () -> + assertTrue( + ready.await(10, TimeUnit.SECONDS))); + transaction + .ontologyEntityDAO() + .insert( + ontology, + ontology.getFullyQualifiedName()); + return null; + })); + } + })); + } + for (final var result : results) { + result.get(30, TimeUnit.SECONDS); + } + } + }); + assertEquals( + 2, + handle + .createQuery("SELECT COUNT(*) FROM ontology_entity") + .mapTo(Integer.class) + .one()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void concurrentWritersInitializeAndLockWithoutDeadlocking(ConnectionType dialect) { + withDatabase( + dialect, + handle -> { + migrate(handle, dialect); + UUID ontologyId = UUID.randomUUID(); + handle.attach(OntologyStateDAO.class).initialize(ontologyId); + assertDoesNotThrow( + () -> { + try (var workers = Executors.newFixedThreadPool(8)) { + CountDownLatch ready = new CountDownLatch(8); + CountDownLatch start = new CountDownLatch(1); + List> writes = new ArrayList<>(); + for (int writer = 0; writer < 8; writer++) { + writes.add( + workers.submit( + () -> { + try (var connection = handle.getJdbi().open()) { + OntologyStateDAO state = connection.attach(OntologyStateDAO.class); + ready.countDown(); + assertTrue(start.await(10, TimeUnit.SECONDS)); + for (int edit = 0; edit < 20; edit++) { + connection.useTransaction( + transaction -> { + state.initialize(ontologyId); + state.lock(ontologyId); + state.advanceWorkingRevision(ontologyId); + }); + } + } + return null; + })); + } + assertTrue(ready.await(10, TimeUnit.SECONDS)); + start.countDown(); + for (Future write : writes) { + write.get(30, TimeUnit.SECONDS); + } + } + }); + assertEquals( + 160L, handle.attach(OntologyStateDAO.class).get(ontologyId).getWorkingRevision()); + }); + } + + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void staleBulkSnapshotsCannotOverwriteNewerWorkingContent(ConnectionType dialect) { + withDatabase( + dialect, + handle -> { + migrate(handle, dialect); + CollectionDAO dao = handle.attach(CollectionDAO.class); + for (double version : List.of(0.1, 0.2)) { + Ontology original = definition(); + Ontology current = + JsonUtils.deepCopy(original, Ontology.class) + .withDescription("A newer working edit") + .withVersion(version) + .withUpdatedAt(2L); + dao.ontologyEntityDAO().insert(current, current.getFullyQualifiedName()); + dao.ontologyStateDAO().initialize(current.getId()); + dao.ontologyStateDAO().advanceWorkingRevision(current.getId()); + OntologyWorkingState state = new OntologyWorkingState(dao); + assertThrows( + PreconditionFailedException.class, + () -> + dao.inTransaction( + ignored -> + state.mutate( + dao.ontologyEntityDAO(), + List.of(original), + () -> { + dao.ontologyEntityDAO().update(original.withDeleted(true)); + return null; + }))); + assertEquals(current, dao.ontologyEntityDAO().findEntityById(current.getId())); + assertEquals(1L, dao.ontologyStateDAO().get(current.getId()).getWorkingRevision()); + } + }); + } + + private static Ontology definition() { + String name = "Concurrent" + UUID.randomUUID(); + return new Ontology() + .withId(UUID.randomUUID()) + .withName(name) + .withFullyQualifiedName(name) + .withIri(URI.create("urn:ontology:" + name)) + .withNamespace(URI.create("urn:concept:")) + .withVersion(0.1) + .withUpdatedAt(1L) + .withUpdatedBy("admin"); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/NativeOntologyChangeSetTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/NativeOntologyChangeSetTest.java new file mode 100644 index 000000000000..8595a47667f4 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/NativeOntologyChangeSetTest.java @@ -0,0 +1,141 @@ +package org.openmetadata.service.ontology; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.ws.rs.BadRequestException; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.entity.context.ContextMemory; +import org.openmetadata.schema.entity.context.ContextMemoryStatus; +import org.openmetadata.schema.entity.data.OntologyChangeSet; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.type.OntologyChangeOperationType; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.governance.approval.ApprovalGate; + +class NativeOntologyChangeSetTest { + private static final String DRAFT = + """ + { + "ontology": {"id":"2ecf752a-956d-4a44-95ec-cce029f5c1d7", "type":"ontology"}, + "baseWorkingRevision": 0, + "glossaries": [], + "undoCursor": 1, + "operations": [{ + "id":"83762099-0b59-4125-9fe1-252becb42185", "operationType":"UPSERT_CONCEPT", + "concept": { + "id":"5b7de8e7-9daa-4162-9659-a9318113c968", "name":"Customer", + "fullyQualifiedName":"Sales.Customer", "conceptType":"OWL_CLASS", "iri":"urn:Customer", + "ontology":{"id":"2ecf752a-956d-4a44-95ec-cce029f5c1d7", "type":"ontology"} + } + }] + } + """; + + @Test + void nativeConceptProposalsUseTheExistingChangeSetLifecycle() { + final var draft = draft(); + assertDoesNotThrow(() -> OntologyChangeSetValidator.normalizeAndValidate(draft)); + } + + @Test + void nativeProposalsCannotTargetAnotherOntology() { + final var json = JsonUtils.readTree(DRAFT); + ((ObjectNode) json.path("operations").get(0).path("concept").path("ontology")) + .put("id", "56b0cc3a-1139-41dd-a16d-bcd12e597eb9"); + final var draft = JsonUtils.convertValue(json, OntologyChangeSet.class); + assertThrows( + BadRequestException.class, () -> OntologyChangeSetValidator.normalizeAndValidate(draft)); + } + + @Test + void nativeAndGlossaryScopesCannotBeMixed() { + final var draft = + JsonUtils.readValue( + DRAFT.replace( + "\"glossaries\": []", + "\"glossaries\": [{\"id\":\"56b0cc3a-1139-41dd-a16d-bcd12e597eb9\",\"type\":\"glossary\"}]"), + OntologyChangeSet.class); + assertThrows( + BadRequestException.class, () -> OntologyChangeSetValidator.normalizeAndValidate(draft)); + } + + @Test + void oneDraftCannotMutateTheSameDefinitionTwice() { + final var draft = draft(); + final var first = draft.getOperations().getFirst(); + final var duplicate = JsonUtils.deepCopy(first, first.getClass()).withId(UUID.randomUUID()); + draft.setOperations(List.of(first, duplicate)); + assertThrows( + BadRequestException.class, () -> OntologyChangeSetValidator.normalizeAndValidate(draft)); + } + + @Test + void deletesRequireAVersionedTargetAndNoDefinitionPayload() { + final var draft = draft(); + final var operation = draft.getOperations().getFirst(); + operation.setOperationType(OntologyChangeOperationType.DELETE_CONCEPT); + operation.setTargetId(operation.getConcept().getId()); + operation.setBaseVersion(0.1); + assertThrows( + BadRequestException.class, () -> OntologyChangeSetValidator.normalizeAndValidate(draft)); + operation.setConcept(null); + assertDoesNotThrow(() -> OntologyChangeSetValidator.normalizeAndValidate(draft)); + operation.setBaseVersion(null); + assertThrows( + BadRequestException.class, () -> OntologyChangeSetValidator.normalizeAndValidate(draft)); + } + + @Test + void changedDefinitionsFailPreflightBeforeAnyOperationRuns() { + final var draft = draft(); + final var operation = draft.getOperations().getFirst(); + operation.setTargetId(operation.getConcept().getId()); + operation.setBaseVersion(0.1); + final var current = JsonUtils.deepCopy(operation.getConcept(), OntologyConcept.class); + current.setVersion(0.2); + final var preflight = new OntologyChangePreflight((type, id) -> current); + assertThrows(BadRequestException.class, () -> preflight.validate(draft, draft.getOperations())); + current.setVersion(0.1); + assertDoesNotThrow(() -> preflight.validate(draft, draft.getOperations())); + current.setDeleted(true); + assertThrows(BadRequestException.class, () -> preflight.validate(draft, draft.getOperations())); + } + + @Test + void nativeProposalsRequireAnActiveSourceMemory() { + final var draft = draft(); + final var source = new ContextMemory().withId(UUID.randomUUID()); + draft.getOperations().getFirst().setSourceMemoryIds(Set.of(source.getId())); + final var preflight = new OntologyChangePreflight((type, id) -> source); + assertThrows(BadRequestException.class, () -> preflight.validate(draft, draft.getOperations())); + source.setEntityStatus(ContextMemoryStatus.APPROVED); + assertDoesNotThrow(() -> preflight.validate(draft, draft.getOperations())); + source.setDeleted(true); + assertThrows(BadRequestException.class, () -> preflight.validate(draft, draft.getOperations())); + } + + @Test + void nativeWorkingEditsAreGovernedByTheReleaseInsteadOfHeldIndividually() { + final var before = new OntologyConcept().withId(UUID.randomUUID()).withName("Customer"); + final var after = + new OntologyConcept() + .withId(before.getId()) + .withName("Customer") + .withDescription("Updated working definition"); + assertTrue( + ApprovalGate.admit(before, after, "steward", null, ApprovalGate.Edit.put()).isEmpty()); + assertTrue(ApprovalGate.preview(before, after, "steward", ApprovalGate.Edit.put()).isEmpty()); + assertFalse(ApprovalGate.mayHold("ontologyConcept", "steward")); + } + + private static OntologyChangeSet draft() { + return JsonUtils.readValue(DRAFT, OntologyChangeSet.class); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyChangeOperationExecutorTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyChangeOperationExecutorTest.java index 4122cbcd3cb3..d008ea699598 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyChangeOperationExecutorTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyChangeOperationExecutorTest.java @@ -42,7 +42,7 @@ import org.openmetadata.schema.api.data.ConceptMapping; import org.openmetadata.schema.entity.data.Glossary; import org.openmetadata.schema.entity.data.GlossaryTerm; -import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyChangeSet; import org.openmetadata.schema.type.EventType; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.OntologyAttribute; @@ -50,7 +50,6 @@ import org.openmetadata.schema.type.OntologyChangeOperationType; import org.openmetadata.service.jdbi3.GlossaryRepository; import org.openmetadata.service.jdbi3.GlossaryTermRepository; -import org.openmetadata.service.jdbi3.OntologyAxiomRepository; import org.openmetadata.service.ontology.OntologyChangeOperationExecutor.OperationOutcome; import org.openmetadata.service.util.RestUtil.DeleteResponse; import org.openmetadata.service.util.RestUtil.PutResponse; @@ -66,15 +65,12 @@ class OntologyChangeOperationExecutorTest { private final UriInfo uriInfo = mock(UriInfo.class); private final GlossaryTermRepository termRepository = mock(GlossaryTermRepository.class); private final GlossaryRepository glossaryRepository = mock(GlossaryRepository.class); - private final OntologyAxiomRepository axiomRepository = mock(OntologyAxiomRepository.class); private final Clock clock = Clock.fixed(Instant.ofEpochMilli(NOW), ZoneOffset.UTC); private OntologyChangeOperationExecutor executor; @BeforeEach void setUp() { - executor = - new OntologyChangeOperationExecutor( - glossaryRepository, termRepository, axiomRepository, clock); + executor = new OntologyChangeOperationExecutor(glossaryRepository, termRepository, clock); } @Test @@ -349,77 +345,28 @@ void deleteTermDeletesActiveTerm() { } @Test - void deleteAxiomOnAlreadyDeletedAxiomSkipsRepositoryDelete() { - final UUID axiomId = UUID.randomUUID(); - final OntologyAxiom alreadyDeleted = axiom(axiomId).withDeleted(true); - when(axiomRepository.get(isNull(), eq(axiomId), any(), eq(Include.ALL), eq(false))) - .thenReturn(alreadyDeleted); - final OntologyChangeOperation operation = - operation(OntologyChangeOperationType.DELETE_AXIOM, axiomId); - - final OperationOutcome outcome = executor.execute(uriInfo, USER, operation); - - verify(axiomRepository, never()).delete(any(), any(), eq(false), eq(false)); - assertEquals(axiomId, outcome.entity().getId()); - } - - @Test - void deleteAxiomDeletesActiveAxiom() { - final UUID axiomId = UUID.randomUUID(); - final OntologyAxiom active = axiom(axiomId).withDeleted(false); - final OntologyAxiom deleted = axiom(axiomId).withDeleted(true); - when(axiomRepository.get(isNull(), eq(axiomId), any(), eq(Include.ALL), eq(false))) - .thenReturn(active); - when(axiomRepository.delete(eq(USER), eq(axiomId), eq(false), eq(false))) - .thenReturn(new DeleteResponse<>(deleted, EventType.ENTITY_DELETED)); - final OntologyChangeOperation operation = - operation(OntologyChangeOperationType.DELETE_AXIOM, axiomId); - - final OperationOutcome outcome = executor.execute(uriInfo, USER, operation); - - verify(axiomRepository).delete(USER, axiomId, false, false); - assertEquals(axiomId, outcome.entity().getId()); - } - - @Test - void upsertAxiomWithTargetIdPreparesAsUpdate() { - final UUID axiomId = UUID.randomUUID(); - stubAxiomUpsertEchoesEntity(); - final OntologyChangeOperation operation = - operation(OntologyChangeOperationType.UPSERT_AXIOM, axiomId).withAxiom(axiom(axiomId)); - - executor.execute(uriInfo, USER, operation); - - verify(axiomRepository).prepareInternal(any(OntologyAxiom.class), eq(true)); - verify(axiomRepository, never()).prepareInternal(any(OntologyAxiom.class), eq(false)); - } - - @Test - void upsertAxiomWithoutTargetIdPreparesAsCreate() { - final UUID axiomId = UUID.randomUUID(); - stubAxiomUpsertEchoesEntity(); - final OntologyChangeOperation operation = - operation(OntologyChangeOperationType.UPSERT_AXIOM, null).withAxiom(axiom(axiomId)); - - executor.execute(uriInfo, USER, operation); - - verify(axiomRepository).prepareInternal(any(OntologyAxiom.class), eq(false)); - verify(axiomRepository, never()).prepareInternal(any(OntologyAxiom.class), eq(true)); - } - - @Test - void upsertAxiomStampsUpdatedByAndUpdatedAt() { - final UUID axiomId = UUID.randomUUID(); - stubAxiomUpsertEchoesEntity(); - final OntologyChangeOperation operation = - operation(OntologyChangeOperationType.UPSERT_AXIOM, axiomId).withAxiom(axiom(axiomId)); - - executor.execute(uriInfo, USER, operation); - - final ArgumentCaptor captor = ArgumentCaptor.forClass(OntologyAxiom.class); - verify(axiomRepository).createOrUpdate(eq(uriInfo), captor.capture(), eq(USER)); - assertEquals(USER, captor.getValue().getUpdatedBy()); - assertEquals(NOW, captor.getValue().getUpdatedAt()); + void glossaryChangeSetsRejectNativeAxiomOperations() { + for (final var type : + List.of( + OntologyChangeOperationType.UPSERT_AXIOM, OntologyChangeOperationType.DELETE_AXIOM)) { + final var operation = operation(type, UUID.randomUUID()); + assertThrows(BadRequestException.class, () -> executor.execute(uriInfo, USER, operation)); + assertThrows( + BadRequestException.class, + () -> + OntologyChangeSetValidator.normalizeAndValidate( + new OntologyChangeSet().withOperations(List.of(operation)))); + final var preflight = + new OntologyChangePreflight( + (entityType, id) -> { + throw new AssertionError("Unsupported operations must not load entities"); + }); + assertThrows( + BadRequestException.class, + () -> + preflight.validate( + new OntologyChangeSet().withGlossaries(List.of()), List.of(operation))); + } } private void stubEditableTerm(final UUID termId, final GlossaryTerm term) { @@ -475,16 +422,6 @@ private FieldUpdate captureFieldUpdate() { return new FieldUpdate(termCaptor.getValue(), fieldsCaptor.getValue()); } - private void stubAxiomUpsertEchoesEntity() { - when(axiomRepository.createOrUpdate(eq(uriInfo), any(OntologyAxiom.class), eq(USER))) - .thenAnswer( - invocation -> - new PutResponse<>( - Response.Status.OK, - invocation.getArgument(1, OntologyAxiom.class), - EventType.ENTITY_UPDATED)); - } - private GlossaryTerm capturePersistedTerm() { final ArgumentCaptor captor = ArgumentCaptor.forClass(GlossaryTerm.class); verify(termRepository).createOrUpdate(eq(uriInfo), captor.capture(), eq(USER)); @@ -517,14 +454,6 @@ private static ConceptMapping mapping( .withSchemeIri(schemeIri); } - private static OntologyAxiom axiom(final UUID axiomId) { - return new OntologyAxiom() - .withId(axiomId) - .withName("axiom1") - .withFullyQualifiedName("ontology.axiom1") - .withVersion(0.5); - } - private static OntologyChangeOperation operation( final OntologyChangeOperationType type, final UUID targetId) { return new OntologyChangeOperation() diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyDefinitionTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyDefinitionTest.java new file mode 100644 index 000000000000..910a0e6b4849 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyDefinitionTest.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.ontology; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import jakarta.validation.ConstraintViolationException; +import java.net.URI; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.api.data.CreateOntologyConcept; +import org.openmetadata.schema.api.data.CreateOntologyProperty; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.utils.JsonUtils; + +class OntologyDefinitionTest { + private static final URI NAMESPACE = URI.create("https://example.org/sales/"); + + @Test + void definitionsRoundTripWithoutGlossaryFields() { + Ontology ontology = ontology(); + OntologyConcept concept = concept(ontology); + OntologyProperty property = + new OntologyProperty() + .withId(UUID.randomUUID()) + .withName("customerId") + .withFullyQualifiedName("Sales.customerId") + .withOntology(reference(ontology)) + .withIri(NAMESPACE.resolve("customerId")) + .withKind(OntologyPropertyKind.DATA); + + assertDoesNotThrow(() -> JsonUtils.validateJsonSchema(ontology, Ontology.class)); + assertDoesNotThrow(() -> JsonUtils.validateJsonSchema(concept, OntologyConcept.class)); + assertDoesNotThrow(() -> JsonUtils.validateJsonSchema(property, OntologyProperty.class)); + assertEquals( + concept, JsonUtils.readValue(JsonUtils.pojoToJson(concept), OntologyConcept.class)); + assertEquals( + property, JsonUtils.readValue(JsonUtils.pojoToJson(property), OntologyProperty.class)); + assertNull(ontology.getEntityStatus()); + assertNull(concept.getEntityStatus()); + assertNull(property.getEntityStatus()); + } + + @Test + void nativeCreationRequestsCanOmitTheSemanticIri() { + CreateOntologyConcept concept = + new CreateOntologyConcept() + .withName("Customer") + .withOntology("Sales") + .withConceptType(OntologyConceptType.OWL_CLASS); + CreateOntologyProperty property = + new CreateOntologyProperty() + .withName("customerId") + .withOntology("Sales") + .withKind(OntologyPropertyKind.DATA); + + assertDoesNotThrow(() -> JsonUtils.validateJsonSchema(concept, CreateOntologyConcept.class)); + assertDoesNotThrow(() -> JsonUtils.validateJsonSchema(property, CreateOntologyProperty.class)); + } + + @Test + void propertyReferencesPreserveExactIriIdentity() { + String references = + "[\"HTTPS://EXAMPLE.org/Customer\",\"https://example.org/Customer\",\"urn:example:客户\"]"; + OntologyProperty property = + JsonUtils.readValue( + "{\"domain\":" + + references + + ",\"range\":" + + references + + ",\"disjointWith\":" + + references + + "}", + OntologyProperty.class); + List expected = + List.of("HTTPS://EXAMPLE.org/Customer", "https://example.org/Customer", "urn:example:客户"); + + assertEquals(expected, property.getDomain().stream().map(URI::toString).toList()); + assertEquals(expected, property.getRange().stream().map(URI::toString).toList()); + assertEquals(expected, property.getDisjointWith().stream().map(URI::toString).toList()); + } + + @Test + void conceptRequiresItsOwnOntologyAndSemanticIdentity() { + OntologyConcept concept = concept(ontology()).withOntology(null); + assertThrows( + ConstraintViolationException.class, + () -> JsonUtils.validateJsonSchema(concept, OntologyConcept.class)); + concept.setOntology(new EntityReference().withId(UUID.randomUUID()).withType("ontology")); + concept.setIri(null); + assertThrows( + ConstraintViolationException.class, + () -> JsonUtils.validateJsonSchema(concept, OntologyConcept.class)); + } + + @Test + void propertyRequiresAnExplicitKind() { + OntologyProperty property = + new OntologyProperty() + .withId(UUID.randomUUID()) + .withName("value") + .withFullyQualifiedName("Sales.value") + .withOntology(reference(ontology())) + .withIri(NAMESPACE.resolve("value")); + assertThrows( + ConstraintViolationException.class, + () -> JsonUtils.validateJsonSchema(property, OntologyProperty.class)); + property.setKind(OntologyPropertyKind.ANNOTATION); + assertDoesNotThrow(() -> JsonUtils.validateJsonSchema(property, OntologyProperty.class)); + } + + @Test + void ontologyRequiresANamespaceForNativeDefinitions() { + Ontology ontology = ontology().withNamespace(null); + assertThrows( + ConstraintViolationException.class, + () -> JsonUtils.validateJsonSchema(ontology, Ontology.class)); + } + + private static EntityReference reference(Ontology ontology) { + return new EntityReference().withId(ontology.getId()).withType("ontology"); + } + + private static Ontology ontology() { + return new Ontology() + .withId(UUID.randomUUID()) + .withName("Sales") + .withFullyQualifiedName("Sales") + .withIri(URI.create("https://example.org/sales")) + .withNamespace(NAMESPACE); + } + + private static OntologyConcept concept(Ontology ontology) { + return new OntologyConcept() + .withId(UUID.randomUUID()) + .withName("Customer") + .withFullyQualifiedName("Sales.Customer") + .withOntology(reference(ontology)) + .withIri(NAMESPACE.resolve("Customer")) + .withConceptType(OntologyConceptType.OWL_CLASS); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyIriMinterTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyIriMinterTest.java index 8172a65f2086..587d1f8703fc 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyIriMinterTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyIriMinterTest.java @@ -20,6 +20,8 @@ import java.net.URI; import java.util.UUID; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.openmetadata.schema.api.data.OntologyIriPreview; import org.openmetadata.schema.api.data.OntologyIriPreviewRequest; import org.openmetadata.schema.entity.data.Glossary; @@ -29,6 +31,19 @@ class OntologyIriMinterTest { private static final UUID GLOSSARY_ID = UUID.fromString("209fd87f-1e64-4197-9264-3d9a8bd42a62"); private static final UUID CANDIDATE_ID = UUID.fromString("78ba2f96-3584-4429-850f-cef7d4f5d876"); + @ParameterizedTest + @CsvSource({ + "https://example.org/sales, https://example.org/sales/", + "https://example.org/sales/, https://example.org/sales/", + "https://example.org/sales#, https://example.org/sales#", + "urn:example:sales:, urn:example:sales:" + }) + void mintsNativeIdentityFromTheNamespaceAndStableId(String namespace, String prefix) { + assertEquals( + prefix + CANDIDATE_ID, + OntologyIriMinter.mint(URI.create(namespace), CANDIDATE_ID).toString()); + } + @Test void expandsEverySupportedPlaceholderAndEncodesSegments() { Glossary glossary = glossary("Financial Risk", "models/{glossary}/{term}/{uuid}"); diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyLayerValidatorTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyLayerValidatorTest.java index e779d739ead5..87589c79bbe8 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyLayerValidatorTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyLayerValidatorTest.java @@ -88,6 +88,21 @@ void rejectsPrefixNamesThatDifferOnlyByCase() { assertThrows(BadRequestException.class, () -> validator.applyDefaultsAndValidate(glossary)); } + @Test + void preservesDistinctNamespaceSpellings() { + List prefixes = + List.of( + prefix("upper", "HTTPS://EXAMPLE.org/"), + prefix("lower", "https://example.org/"), + prefix("segments", "https://example.org/part/../")); + Glossary glossary = model(L3_ID, "Application", OntologyLayer.L_3, List.of()); + glossary.getOntologyConfiguration().setPrefixes(prefixes); + + validator(glossary).applyDefaultsAndValidate(glossary); + + assertEquals(prefixes, glossary.getOntologyConfiguration().getPrefixes()); + } + private static OntologyLayerValidator validator(final Glossary... glossaries) { return new OntologyLayerValidator( id -> diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReferencesTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReferencesTest.java new file mode 100644 index 000000000000..0f77ed441036 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReferencesTest.java @@ -0,0 +1,178 @@ +package org.openmetadata.service.ontology; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.api.data.ConceptMapping; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyLiteral; +import org.openmetadata.schema.type.OntologyReference; +import org.openmetadata.service.exception.BadRequestException; + +class OntologyReferencesTest { + private static final EntityReference OWNER = + new EntityReference().withId(UUID.randomUUID()).withType("ontology"); + + @Test + void propertiesIndexEveryReferenceRoleWithoutIndexingTheirOwnDeclaration() { + OntologyProperty property = + new OntologyProperty() + .withId(UUID.randomUUID()) + .withOntology(OWNER) + .withIri(iri("declared")) + .withDomain(List.of(iri("shared"))) + .withRange(List.of(iri("shared"))) + .withInverse(iri("inverse")) + .withPropertyChain(List.of(iri("first"), iri("second"), iri("first"))) + .withDisjointWith(List.of(iri("disjoint"))) + .withEnumValues( + List.of(new OntologyLiteral().withValue("value").withDatatypeIri(iri("datatype")))); + List references = OntologyReferences.collect(property); + assertEquals( + Set.of( + "DOMAIN:shared", + "RANGE:shared", + "INVERSE:inverse", + "PROPERTY_CHAIN:first", + "PROPERTY_CHAIN:second", + "DISJOINT_PROPERTY:disjoint", + "DATATYPE:datatype"), + keys(references)); + for (OntologyReference reference : references) { + assertEquals(OWNER.getId(), reference.getOntologyId()); + assertEquals(property.getId(), reference.getSource().getId()); + assertEquals("ontologyProperty", reference.getSource().getType()); + } + } + + @Test + void mappingsPreserveCaseDistinctIrisAndDeduplicateOnlyTheSameRoleAndSpelling() { + OntologyConcept concept = + new OntologyConcept() + .withId(UUID.randomUUID()) + .withOntology(OWNER) + .withConceptMappings( + List.of( + mapping("urn:Example:Customer", "urn:example:scheme"), + mapping("urn:example:Customer", "urn:example:scheme"), + mapping("urn:example:Customer", null))); + assertEquals( + Set.of( + "MAPPING_TARGET:Example:Customer", + "MAPPING_TARGET:example:Customer", + "MAPPING_SCHEME:example:scheme"), + keys(OntologyReferences.collect(concept))); + } + + @Test + void nestedExpressionsAndAssertionsIncludeIndividualsPropertiesAndDatatypes() { + OntologyExpression nested = + new OntologyExpression() + .withKind(OntologyExpressionKind.INTERSECTION) + .withOperands( + List.of( + new OntologyExpression() + .withKind(OntologyExpressionKind.NAMED_CLASS) + .withClassIri(iri("class")), + new OntologyExpression() + .withKind(OntologyExpressionKind.RESTRICTION) + .withPropertyIri(iri("nestedProperty")) + .withFiller( + new OntologyExpression() + .withKind(OntologyExpressionKind.ONE_OF) + .withIndividualIris(List.of(iri("one"), iri("two")))), + new OntologyExpression() + .withKind(OntologyExpressionKind.RESTRICTION) + .withPropertyIri(iri("valueProperty")) + .withIndividualIri(iri("value")), + new OntologyExpression() + .withKind(OntologyExpressionKind.RESTRICTION) + .withPropertyIri(iri("dataProperty")) + .withLiteral( + new OntologyLiteral().withValue("1").withDatatypeIri(iri("type"))))); + OntologyAxiom axiom = + new OntologyAxiom() + .withId(UUID.randomUUID()) + .withOntology(OWNER) + .withSubjectIri(iri("subject")) + .withPropertyIri(iri("assertedProperty")) + .withTargetIri(iri("target")) + .withLiteral(new OntologyLiteral().withValue("2").withDatatypeIri(iri("type"))) + .withExpressions(List.of(nested)); + assertEquals( + Set.of( + "AXIOM_SUBJECT:subject", + "PROPERTY:assertedProperty", + "INDIVIDUAL:target", + "CLASS_EXPRESSION:class", + "PROPERTY:nestedProperty", + "INDIVIDUAL:one", + "INDIVIDUAL:two", + "PROPERTY:valueProperty", + "INDIVIDUAL:value", + "PROPERTY:dataProperty", + "DATATYPE:type"), + keys(OntologyReferences.collect(axiom))); + } + + @Test + void removedDefinitionsAndNamespacesHaveNoOutgoingReferences() { + assertTrue(OntologyReferences.collect(new Ontology().withId(OWNER.getId())).isEmpty()); + assertTrue( + OntologyReferences.collect( + new OntologyConcept() + .withDeleted(true) + .withConceptMappings(List.of(mapping("urn:target", null)))) + .isEmpty()); + } + + @Test + void indexingRejectsRelativeReferencesAndBoundsExpressionTraversal() { + OntologyProperty property = + new OntologyProperty() + .withId(UUID.randomUUID()) + .withOntology(OWNER) + .withInverse(URI.create("relative")); + assertThrows(BadRequestException.class, () -> OntologyReferences.collect(property)); + OntologyExpression cyclic = new OntologyExpression().withKind(OntologyExpressionKind.UNION); + cyclic.setOperands(List.of(cyclic)); + OntologyAxiom axiom = + new OntologyAxiom() + .withId(UUID.randomUUID()) + .withOntology(OWNER) + .withExpressions(List.of(cyclic)); + assertThrows(BadRequestException.class, () -> OntologyReferences.collect(axiom)); + } + + private static ConceptMapping mapping(String target, String scheme) { + return new ConceptMapping() + .withConceptIri(URI.create(target)) + .withMappingType(ConceptMapping.ConceptMappingType.EXACT_MATCH) + .withSchemeIri(scheme == null ? null : URI.create(scheme)); + } + + private static URI iri(String value) { + return URI.create("urn:" + value); + } + + private static Set keys(List references) { + return references.stream() + .map( + reference -> + reference.getRole().value() + ":" + reference.getIri().toString().substring(4)) + .collect(Collectors.toSet()); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReleaseComparisonTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReleaseComparisonTest.java new file mode 100644 index 000000000000..bcf8a299def2 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReleaseComparisonTest.java @@ -0,0 +1,165 @@ +package org.openmetadata.service.ontology; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.OntologyReleaseChangeType; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.exception.BadRequestException; + +class OntologyReleaseComparisonTest { + private static final UUID ONTOLOGY_ID = UUID.randomUUID(); + private static final UUID BASE_ID = UUID.randomUUID(); + private static final UUID RELEASE_ID = UUID.randomUUID(); + + @Test + void operationalChangesAndReferenceDisplayFieldsDoNotChangeGovernedContent() { + final var before = concept(); + final var after = + JsonUtils.deepCopy(before, OntologyConcept.class) + .withVersion(0.8) + .withUpdatedBy("reviewer") + .withUpdatedAt(20L); + after.getOntology().withName("Hydrated owner").withHref(URI.create("https://catalog/owner")); + assertTrue( + OntologyReleaseComparison.compareMembers( + List.of(member(BASE_ID, before)), List.of(member(RELEASE_ID, after))) + .isEmpty()); + } + + @Test + void changedMeaningRetainsBothSnapshotAddresses() { + final var before = concept(); + final var after = JsonUtils.deepCopy(before, OntologyConcept.class).withDescription("Revised"); + final var changes = + OntologyReleaseComparison.compareMembers( + List.of(member(BASE_ID, before)), List.of(member(RELEASE_ID, after))); + assertEquals(1, changes.size()); + final var change = changes.getFirst(); + assertEquals(OntologyReleaseChangeType.CHANGED, change.getChangeType()); + assertEquals(BASE_ID, change.getBeforeReleaseId()); + assertEquals(RELEASE_ID, change.getAfterReleaseId()); + assertEquals(before.getId(), change.getEntity().getId()); + assertEquals(ONTOLOGY_ID, change.getOntologyId()); + assertNull(change.getEntity().getDescription()); + } + + @Test + void replacementUnderTheSameNameAndIriIsAnAdditionAndRemoval() { + final var before = concept(); + final var after = JsonUtils.deepCopy(before, OntologyConcept.class).withId(UUID.randomUUID()); + final var changes = + OntologyReleaseComparison.compareMembers( + List.of(member(BASE_ID, before)), List.of(member(RELEASE_ID, after))); + assertEquals(2, changes.size()); + final var removed = + changes.stream() + .filter(change -> change.getEntity().getId().equals(before.getId())) + .findFirst() + .orElseThrow(); + assertEquals(OntologyReleaseChangeType.REMOVED, removed.getChangeType()); + assertNull(removed.getAfterReleaseId()); + final var added = + changes.stream() + .filter(change -> change.getEntity().getId().equals(after.getId())) + .findFirst() + .orElseThrow(); + assertEquals(OntologyReleaseChangeType.ADDED, added.getChangeType()); + assertNull(added.getBeforeReleaseId()); + } + + @Test + void exactIriSpellingAndOrderedPropertyChainsRemainSignificant() { + final var before = concept().withIri(URI.create("HTTPS://EXAMPLE.org/Customer")); + final var after = + JsonUtils.deepCopy(before, OntologyConcept.class) + .withIri(URI.create("https://example.org/Customer")); + assertEquals( + 1, + OntologyReleaseComparison.compareMembers( + List.of(member(BASE_ID, before)), List.of(member(RELEASE_ID, after))) + .size()); + final var property = + new OntologyProperty() + .withId(UUID.randomUUID()) + .withName("chain") + .withOntology(before.getOntology()) + .withKind(OntologyPropertyKind.OBJECT) + .withPropertyChain(List.of(URI.create("urn:first"), URI.create("urn:second"))); + final var reordered = + JsonUtils.deepCopy(property, OntologyProperty.class) + .withPropertyChain(property.getPropertyChain().reversed()); + assertEquals( + 1, + OntologyReleaseComparison.compareMembers( + List.of(member(BASE_ID, property)), List.of(member(RELEASE_ID, reordered))) + .size()); + } + + @Test + void memberKindDisambiguatesIdentityAndOrderingDoesNotDependOnInputOrder() { + final var concept = concept(); + final var property = + new OntologyProperty() + .withId(concept.getId()) + .withName("property") + .withOntology(concept.getOntology()); + final var first = member(RELEASE_ID, concept); + final var second = member(RELEASE_ID, property); + final var changes = OntologyReleaseComparison.compareMembers(List.of(), List.of(second, first)); + assertEquals( + List.of(MemberKind.CONCEPT, MemberKind.PROPERTY), + changes.stream().map(change -> change.getMemberKind()).toList()); + assertEquals( + changes, OntologyReleaseComparison.compareMembers(List.of(), List.of(first, second))); + } + + @Test + void duplicateIdentityAndOversizedClosuresAreRejected() { + final var member = member(RELEASE_ID, concept()); + assertThrows( + BadRequestException.class, + () -> OntologyReleaseComparison.compareMembers(List.of(), List.of(member, member))); + assertThrows( + BadRequestException.class, + () -> + OntologyReleaseComparison.compareMembers( + List.of(), Collections.nCopies(10_001, member))); + } + + private static OntologyConcept concept() { + return new OntologyConcept() + .withId(UUID.randomUUID()) + .withName("Customer") + .withFullyQualifiedName("Sales.Customer") + .withIri(URI.create("urn:Customer")) + .withOntology(new EntityReference().withId(ONTOLOGY_ID).withType("ontology")) + .withConceptType(OntologyConceptType.OWL_CLASS) + .withVersion(0.1) + .withUpdatedAt(1L) + .withUpdatedBy("author"); + } + + private static OntologyReleaseMember member(UUID releaseId, OntologyDefinition entity) { + return OntologyReleaseManifest.member( + releaseId, + entity instanceof OntologyProperty ? MemberKind.PROPERTY : MemberKind.CONCEPT, + entity.getId(), + JsonUtils.getObjectMapper().valueToTree(entity)); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReleaseManifestTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReleaseManifestTest.java new file mode 100644 index 000000000000..dfa9d9f06ef0 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReleaseManifestTest.java @@ -0,0 +1,153 @@ +package org.openmetadata.service.ontology; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.exception.BadRequestException; + +class OntologyReleaseManifestTest { + private static final UUID RELEASE = UUID.fromString("916e6b9f-ea75-41a8-bd77-b81a04879243"); + private static final UUID MEMBER = UUID.fromString("7f4ef690-aedc-4ed4-b00b-41cd042f24b4"); + + @Test + void memberDigestsIgnoreJsonObjectOrderButPreserveIriSpelling() { + OntologyReleaseMember first = + member("{\"name\":\"Customer\",\"iri\":\"HTTPS://EXAMPLE.org/客户\"}"); + OntologyReleaseMember reordered = + member("{\"iri\":\"HTTPS://EXAMPLE.org/客户\",\"name\":\"Customer\"}"); + OntologyReleaseMember distinct = + member("{\"iri\":\"https://example.org/客户\",\"name\":\"Customer\"}"); + + assertEquals(first.getPayloadDigest(), reordered.getPayloadDigest()); + assertNotEquals(first.getPayloadDigest(), distinct.getPayloadDigest()); + assertEquals("HTTPS://EXAMPLE.org/客户", first.getPayload().path("iri").asText()); + } + + @Test + void memberDigestsPreserveArrayOrderAndNumericValuesAcrossJsonStorage() { + assertEquals( + member("{\"cardinality\":1.0}").getPayloadDigest(), + member("{\"cardinality\":1}").getPayloadDigest()); + assertNotEquals( + member("{\"propertyChain\":[\"urn:first\",\"urn:second\"]}").getPayloadDigest(), + member("{\"propertyChain\":[\"urn:second\",\"urn:first\"]}").getPayloadDigest()); + } + + @Test + void manifestsAreIndependentOfDatabaseReadOrderAndBindMemberIdentity() { + OntologyReleaseMember concept = member("{\"iri\":\"urn:Customer\"}"); + OntologyReleaseMember property = + OntologyReleaseManifest.member( + RELEASE, MemberKind.PROPERTY, MEMBER, JsonUtils.readTree("{\"iri\":\"urn:knows\"}")); + assertEquals( + OntologyReleaseManifest.digest(List.of(concept, property)), + OntologyReleaseManifest.digest(List.of(property, concept))); + assertNotEquals( + OntologyReleaseManifest.digest(List.of(concept)), + OntologyReleaseManifest.digest(List.of(property))); + assertThrows( + BadRequestException.class, () -> OntologyReleaseManifest.digest(List.of(concept, concept))); + } + + @Test + void oversizedPayloadsAreRejectedBeforeStorage() { + String payload = + "{\"description\":\"" + "x".repeat(OntologyReleaseManifest.MAX_MEMBER_BYTES) + "\"}"; + assertThrows(BadRequestException.class, () -> member(payload)); + } + + @Test + void rejectsPayloadsThatDatabaseBindingWouldChange() { + assertThrows(BadRequestException.class, () -> member("{\"label\":\"a\\u0000b\"}")); + assertEquals( + "a\\u0000b", member("{\"label\":\"a\\\\u0000b\"}").getPayload().path("label").asText()); + assertDoesNotThrow(() -> member("{\"label\":\"a\\\\u0000b\",\"cardinality\":100.0}")); + } + + @Test + void rejectsMissingMemberIdentityAndInvalidManifests() { + assertThrows( + BadRequestException.class, + () -> + OntologyReleaseManifest.member( + null, MemberKind.CONCEPT, MEMBER, JsonUtils.readTree("{}"))); + assertThrows( + BadRequestException.class, + () -> OntologyReleaseManifest.member(RELEASE, null, MEMBER, JsonUtils.readTree("{}"))); + assertThrows( + BadRequestException.class, + () -> + OntologyReleaseManifest.member( + RELEASE, MemberKind.CONCEPT, null, JsonUtils.readTree("{}"))); + assertThrows(BadRequestException.class, () -> member("[]")); + assertThrows( + BadRequestException.class, + () -> OntologyReleaseManifest.member(RELEASE, MemberKind.CONCEPT, MEMBER, null)); + assertThrows(BadRequestException.class, () -> OntologyReleaseManifest.digest(null)); + assertThrows(BadRequestException.class, () -> OntologyReleaseManifest.digest(List.of())); + assertThrows( + BadRequestException.class, + () -> OntologyReleaseManifest.digest(Collections.singletonList(null))); + assertThrows( + BadRequestException.class, + () -> + OntologyReleaseManifest.digest( + Collections.nCopies(OntologyReleaseManifest.MAX_MEMBERS + 1, member("{}")))); + } + + @Test + void rejectsChangedPayloadOrProvenanceAndMixedReleases() { + OntologyReleaseMember member = + member("{\"version\":0.2,\"reviewRevision\":3,\"iri\":\"urn:Customer\"}"); + String digest = member.getPayloadDigest(); + member.setPayloadDigest("0".repeat(64)); + assertThrows(BadRequestException.class, () -> OntologyReleaseManifest.digest(List.of(member))); + member.withPayloadDigest(digest).withSourceVersion(0.3); + assertThrows(BadRequestException.class, () -> OntologyReleaseManifest.digest(List.of(member))); + member.withSourceVersion(0.2).withPayloadBytes(member.getPayloadBytes() + 1); + assertThrows(BadRequestException.class, () -> OntologyReleaseManifest.digest(List.of(member))); + OntologyReleaseMember anotherRelease = + member("{}").withReleaseId(UUID.randomUUID()).withMemberId(UUID.randomUUID()); + assertThrows( + BadRequestException.class, + () -> OntologyReleaseManifest.digest(List.of(member("{}"), anotherRelease))); + } + + @Test + void limitsTotalCapturedBytes() { + String payload = + "{\"description\":\"" + "x".repeat(OntologyReleaseManifest.MAX_MEMBER_BYTES - 100) + "\"}"; + List members = + IntStream.range(0, 65) + .mapToObj(index -> member(payload).withMemberId(new UUID(0, index))) + .toList(); + assertThrows(BadRequestException.class, () -> OntologyReleaseManifest.digest(members)); + } + + private static OntologyReleaseMember member(String json) { + return OntologyReleaseManifest.member( + RELEASE, MemberKind.CONCEPT, MEMBER, JsonUtils.readTree(json)); + } + + @Test + void derivedHierarchyDoesNotEnterTheSealedPayloadOrItsDigest() { + final var plain = member("{\"iri\":\"urn:Customer\"}"); + final var derived = + member("{\"iri\":\"urn:Customer\",\"superClasses\":[\"urn:Party\"],\"subClasses\":[]}"); + assertEquals(plain.getPayload(), derived.getPayload()); + assertEquals(plain.getPayloadDigest(), derived.getPayloadDigest()); + assertEquals( + OntologyReleaseManifest.digest(List.of(plain)), + OntologyReleaseManifest.digest(List.of(derived))); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReleaseReaderTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReleaseReaderTest.java new file mode 100644 index 000000000000..c5f494024adb --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReleaseReaderTest.java @@ -0,0 +1,61 @@ +package org.openmetadata.service.ontology; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.exception.BadRequestException; + +class OntologyReleaseReaderTest { + @Test + void captureContentExcludesLiveMetadata() { + final OntologyConcept concept = + new OntologyConcept() + .withId(UUID.randomUUID()) + .withName("Customer") + .withIri(URI.create("urn:Customer")) + .withVersion(0.2) + .withUpdatedBy("author") + .withHref(URI.create("https://catalog/concept")); + final var payload = OntologyReleaseContent.project(concept); + for (String field : + List.of( + "version", "updatedBy", "href", "owners", "reviewers", "superClasses", "subClasses")) { + assertFalse(payload.has(field), field); + } + assertEquals("urn:Customer", payload.path("iri").asText()); + } + + @Test + void historicalOperationalFieldsDoNotBreakFrozenDefinitions() { + final UUID id = UUID.randomUUID(); + final var payload = JsonUtils.getObjectNode(); + payload.put("id", id.toString()); + payload.put("iri", "urn:Customer"); + payload.put("entityStatus", "Approved"); + payload.put("retiredField", true); + payload + .putObject("ontology") + .put("id", UUID.randomUUID().toString()) + .put("retiredNestedField", 1); + final var member = + OntologyReleaseManifest.member(UUID.randomUUID(), MemberKind.CONCEPT, id, payload); + + final var concept = (OntologyConcept) OntologyReleaseReader.definition(member); + + assertEquals(id, concept.getId()); + assertEquals("urn:Customer", concept.getIri().toString()); + assertEquals( + payload.path("ontology").path("id").asText(), concept.getOntology().getId().toString()); + assertThrows( + BadRequestException.class, + () -> OntologyReleaseReader.definition(member.withMemberId(UUID.randomUUID()))); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReviewTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReviewTest.java new file mode 100644 index 000000000000..73e1d0ad2436 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReviewTest.java @@ -0,0 +1,159 @@ +package org.openmetadata.service.ontology; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URI; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.OntologyReviewTarget; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.exception.BadRequestException; + +class OntologyReviewTest { + @Test + void governanceAssignmentsDoNotInvalidateApprovedMeaning() { + OntologyRelease approved = + ontology().withEntityStatus(EntityStatus.APPROVED).withReviewTask(UUID.randomUUID()); + OntologyRelease reassigned = + JsonUtils.deepCopy(approved, OntologyRelease.class) + .withOwners(List.of(new EntityReference().withId(UUID.randomUUID()).withType("user"))) + .withReviewers( + List.of(new EntityReference().withId(UUID.randomUUID()).withType("team"))); + assertEquals(OntologyReview.digest(approved), OntologyReview.digest(reassigned)); + assertEquals(EntityStatus.APPROVED, reassigned.getEntityStatus()); + assertEquals(approved.getReviewTask(), reassigned.getReviewTask()); + } + + @Test + void reviewBindsOnlyTheSealedIdentity() { + OntologyRelease original = ontology().withReleaseRevision(2L).withContentDigest("sealed"); + OntologyRelease annotated = + JsonUtils.deepCopy(original, OntologyRelease.class) + .withDescription("New header field") + .withSealedBy("renamed-author") + .withSourceWorkingRevision(123L); + assertEquals(OntologyReview.digest(original), OntologyReview.digest(annotated)); + assertNotEquals( + OntologyReview.digest(original), + OntologyReview.digest(annotated.withContentDigest("different"))); + assertNotEquals( + OntologyReview.digest(original), + OntologyReview.digest(annotated.withContentDigest("sealed").withReleaseRevision(3L))); + } + + @Test + void creationStartsInDraftRegardlessOfRequestedStatus() { + for (EntityStatus requested : EntityStatus.values()) { + OntologyRelease ontology = ontology().withEntityStatus(requested); + OntologyReview.initialize(ontology); + assertEquals(EntityStatus.DRAFT, ontology.getEntityStatus()); + assertEquals("author", ontology.getReviewAuthor()); + } + } + + @Test + void auditAndWorkflowFieldsDoNotChangeTheReviewDigest() { + OntologyRelease original = ontology(); + OntologyRelease updated = + JsonUtils.deepCopy(original, OntologyRelease.class) + .withVersion(0.2) + .withUpdatedAt(123L) + .withUpdatedBy("governance-bot") + .withEntityStatus(EntityStatus.IN_REVIEW); + + assertEquals(OntologyReview.digest(original), OntologyReview.digest(updated)); + } + + @Test + void reviewTargetRetainsTheSubmittedEntityHistoryVersion() { + OntologyRelease submitted = ontology().withVersion(0.4); + OntologyReviewTarget target = OntologyReview.target(submitted); + + assertEquals(0.4, target.getEntityVersion()); + assertEquals(submitted.getOntology().getId(), target.getOntologyId()); + assertDoesNotThrow( + () -> + OntologyReview.validateTarget( + submitted.withVersion(0.5).withEntityStatus(EntityStatus.IN_REVIEW), target)); + assertThrows( + BadRequestException.class, + () -> OntologyReview.validateTarget(submitted, target.withEntityVersion(null))); + } + + @Test + void reviewTargetCannotBeMovedToAnotherOntology() { + final OntologyRelease concept = + new OntologyRelease() + .withId(UUID.randomUUID()) + .withOntology(new EntityReference().withId(UUID.randomUUID()).withType("ontology")) + .withVersion(0.1) + .withReviewAuthor("author"); + final OntologyReviewTarget target = OntologyReview.target(concept); + + assertEquals(concept.getOntology().getId(), target.getOntologyId()); + assertDoesNotThrow(() -> OntologyReview.validateTarget(concept, target)); + target.setOntologyId(UUID.randomUUID()); + assertThrows(BadRequestException.class, () -> OntologyReview.validateTarget(concept, target)); + } + + @Test + void approvalRequiresSomeoneOtherThanBothAuthorAndSubmitter() { + OntologyRelease entity = ontology(); + Task task = new Task().withCreatedBy(new EntityReference().withName("submitter")); + + assertThrows( + BadRequestException.class, + () -> OntologyReview.requireIndependentReviewer(entity, task, "author")); + assertThrows( + BadRequestException.class, + () -> OntologyReview.requireIndependentReviewer(entity, task, "submitter")); + assertDoesNotThrow(() -> OntologyReview.requireIndependentReviewer(entity, task, "reviewer")); + } + + @Test + void referencePresentationDoesNotChangeTheReviewedContent() { + EntityReference reviewer = new EntityReference().withId(UUID.randomUUID()).withType("user"); + OntologyRelease original = ontology().withReviewers(List.of(reviewer)); + OntologyRelease updated = JsonUtils.deepCopy(original, OntologyRelease.class); + updated + .getReviewers() + .getFirst() + .withName("Reviewer") + .withHref(URI.create("https://example.org/user")); + + assertEquals(OntologyReview.digest(original), OntologyReview.digest(updated)); + } + + @Test + void reviewerOrderingDoesNotChangeTheReviewTarget() { + EntityReference first = new EntityReference().withId(UUID.randomUUID()).withType("user"); + EntityReference second = new EntityReference().withId(UUID.randomUUID()).withType("user"); + OntologyRelease original = ontology().withReviewers(List.of(first, second)); + OntologyRelease reordered = + JsonUtils.deepCopy(original, OntologyRelease.class).withReviewers(List.of(second, first)); + + assertEquals(OntologyReview.digest(original), OntologyReview.digest(reordered)); + } + + private static OntologyRelease ontology() { + return new OntologyRelease() + .withId(UUID.randomUUID()) + .withName("Sales") + .withFullyQualifiedName("Sales") + .withVersion(0.1) + .withDescription("Sales model") + .withOntology(new EntityReference().withId(UUID.randomUUID()).withType("ontology")) + .withOntologyIri(URI.create("https://example.org/sales")) + .withUpdatedBy("author") + .withEntityStatus(EntityStatus.DRAFT) + .withReviewAuthor("author"); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyStructureValidatorTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyStructureValidatorTest.java new file mode 100644 index 000000000000..c7433b91199d --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyStructureValidatorTest.java @@ -0,0 +1,448 @@ +package org.openmetadata.service.ontology; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.IntStream; +import org.apache.jena.vocabulary.OWL2; +import org.apache.jena.vocabulary.RDFS; +import org.apache.jena.vocabulary.SKOS; +import org.apache.jena.vocabulary.XSD; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.api.data.ConceptMapping; +import org.openmetadata.schema.api.data.OntologyReleaseStructureReport; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyProfileViolationCode; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.type.OntologyRestrictionKind; +import org.openmetadata.schema.type.RelationshipCharacteristic; +import org.openmetadata.schema.utils.JsonUtils; + +class OntologyStructureValidatorTest { + private static final URI PROPERTY = URI.create("urn:property"); + private static final URI INVERSE = URI.create("urn:inverse"); + private static final OntologyRelease RELEASE = + new OntologyRelease().withId(UUID.randomUUID()).withContentDigest("a".repeat(64)); + + @Test + void declarationsCannotRedefineReservedVocabularyOrInventDatatypes() { + assertFalse(validate(concept("Reserved").withIri(URI.create(XSD.xstring.getURI()))).getValid()); + final var data = property(PROPERTY, OntologyPropertyKind.DATA); + assertTrue(validate(data.withRange(List.of(URI.create(XSD.xstring.getURI())))).getValid()); + assertFalse(validate(data.withRange(List.of(URI.create("urn:UnknownDatatype")))).getValid()); + assertFalse(validate(data.withRange(List.of(URI.create(XSD.date.getURI())))).getValid()); + } + + @Test + void namedSubclassHierarchyRejectsCyclesButAllowsMultipleParents() { + OntologyConcept customer = concept("Customer"); + OntologyConcept party = concept("Party"); + OntologyConcept legalEntity = concept("LegalEntity"); + OntologyAxiom parents = subclass(customer, party, legalEntity); + assertTrue(validate(customer, party, legalEntity, parents).getValid()); + assertFalse( + validate(customer, party, legalEntity, parents, subclass(party, customer)).getValid()); + assertFalse(validate(customer, subclass(customer, customer)).getValid()); + } + + @Test + void targetedValidationRejectsEveryEdgeInOverlappingCycles() { + final var first = concept("First"); + final var second = concept("Second"); + final var third = concept("Third"); + final var leaf = concept("Leaf"); + final var edges = + List.of(subclass(first, second, third), subclass(second, first), subclass(third, second)); + final List definitions = + new ArrayList<>(List.of(first, second, third, leaf)); + definitions.addAll(edges); + final var outside = subclass(leaf, first); + definitions.add(outside); + for (final var edge : edges) { + assertFalse( + OntologyStructureValidator.validateWorking(definitions, Set.of(edge.getId())).getValid(), + "Must reject a cycle when editing " + edge.getSubjectIri()); + } + assertTrue( + OntologyStructureValidator.validateWorking(definitions, Set.of(outside.getId())) + .getValid()); + } + + @Test + void classReferencesMustBeDeclaredAndHaveClassSemantics() { + OntologyConcept customer = concept("Customer"); + OntologyConcept party = concept("Party"); + OntologyAxiom relation = subclass(customer, party); + assertFalse(validate(customer, relation).getValid()); + assertFalse( + validate(customer, party.withConceptType(OntologyConceptType.SKOS_CONCEPT), relation) + .getValid()); + assertFalse( + validate( + customer.withConceptType(OntologyConceptType.SKOS_CONCEPT), + party.withConceptType(OntologyConceptType.OWL_CLASS), + relation) + .getValid()); + assertTrue( + validate(customer.withConceptType(OntologyConceptType.BOTH), party, relation).getValid()); + } + + @Test + void classKindsCannotUseIndividualIdentityMappings() { + OntologyConcept customer = + concept("Customer") + .withConceptMappings( + List.of( + new ConceptMapping() + .withMappingType(ConceptMapping.ConceptMappingType.SAME_AS) + .withConceptIri(URI.create("urn:external:Customer")))); + assertFalse(validate(customer).getValid()); + assertFalse(validate(customer.withConceptType(OntologyConceptType.BOTH)).getValid()); + assertTrue(validate(customer.withConceptType(OntologyConceptType.SKOS_CONCEPT)).getValid()); + } + + @Test + void classRolesInPropertyDomainsAndRangesRequireDeclarations() { + OntologyConcept customer = concept("Customer"); + OntologyProperty owns = + property(PROPERTY, OntologyPropertyKind.OBJECT) + .withDomain(List.of(customer.getIri())) + .withRange(List.of(URI.create(OWL2.Thing.getURI()))); + assertFalse(validate(owns).getValid()); + assertTrue(validate(customer, owns).getValid()); + assertFalse( + validate(customer.withConceptType(OntologyConceptType.SKOS_CONCEPT), owns).getValid()); + } + + @Test + void deepNamedHierarchiesDoNotUseTheJavaCallStack() { + final var definitions = new ArrayList(); + final var concepts = + IntStream.range(0, 4000).mapToObj(index -> concept("Class" + index)).toList(); + definitions.addAll(concepts); + for (int index = 1; index < concepts.size(); index++) { + definitions.add(subclass(concepts.get(index), concepts.get(index - 1))); + } + assertTrue(validate(definitions.toArray(OntologyDefinition[]::new)).getValid()); + definitions.add(subclass(concepts.getFirst(), concepts.getLast())); + assertFalse(validate(definitions.toArray(OntologyDefinition[]::new)).getValid()); + } + + @Test + void classResolutionKeepsExactIriSpellingAndChecksIdentityMappingTargets() { + final var source = concept("Source"); + final var target = concept("Target").withIri(URI.create("https://example.org/Class%2f")); + final var relation = subclass(source, target); + assertTrue(validate(source, target, relation).getValid()); + target.setIri(URI.create("https://example.org/Class%2F")); + assertFalse(validate(source, target, relation).getValid()); + source + .withConceptType(OntologyConceptType.SKOS_CONCEPT) + .withConceptMappings( + List.of( + new ConceptMapping() + .withMappingType(ConceptMapping.ConceptMappingType.SAME_AS) + .withConceptIri(target.getIri()))); + assertFalse(validate(source, target).getValid()); + } + + @ParameterizedTest + @EnumSource(OntologyPropertyKind.class) + void assertionsUseDeclarationsFromAnyMemberRelease(OntologyPropertyKind kind) { + var declaration = property(PROPERTY, kind); + var assertion = assertion(); + var imported = member(UUID.randomUUID(), declaration); + var report = + OntologyStructureValidator.validate( + RELEASE, List.of(imported, member(RELEASE.getId(), assertion))); + assertEquals(kind == OntologyPropertyKind.OBJECT, report.getValid()); + assertEquals(RELEASE.getContentDigest(), report.getContentDigest()); + if (kind != OntologyPropertyKind.OBJECT) { + var finding = report.getViolations().getFirst(); + assertEquals(RELEASE.getId(), finding.getReleaseId()); + assertEquals(assertion.getId(), finding.getMemberId()); + assertEquals(MemberKind.AXIOM, finding.getMemberKind()); + assertEquals(OntologyProfileViolationCode.ILLEGAL_PUNNING, finding.getViolation().getCode()); + } + } + + @Test + void conflictingDeclarationsAreReportedOnBothMembers() { + var object = property(PROPERTY, OntologyPropertyKind.OBJECT); + var data = property(PROPERTY, OntologyPropertyKind.DATA); + var report = validate(object, data); + assertFalse(report.getValid()); + assertEquals(2, report.getViolationCount()); + assertTrue( + report.getViolations().stream() + .allMatch( + issue -> + issue.getViolation().getCode() + == OntologyProfileViolationCode.ILLEGAL_PUNNING)); + assertTrue(validate(object, property(PROPERTY, OntologyPropertyKind.OBJECT)).getValid()); + } + + @Test + void inverseClosureCarriesNonSimplicityAcrossModulesAndCycles() { + var transitive = + property(PROPERTY, OntologyPropertyKind.OBJECT) + .withCharacteristics(Set.of(RelationshipCharacteristic.TRANSITIVE)) + .withInverse(INVERSE); + var inverse = property(INVERSE, OntologyPropertyKind.OBJECT).withInverse(PROPERTY); + var report = + validate( + concept("Subject").withIri(URI.create("urn:subject")), + transitive, + inverse, + cardinality(INVERSE)); + assertEquals(1, report.getViolationCount()); + assertEquals( + OntologyProfileViolationCode.NON_SIMPLE_CARDINALITY, + report.getViolations().getFirst().getViolation().getCode()); + } + + @Test + void chainElementsStaySimpleWhileTheirSuperpropertyDoesNot() { + var first = property(URI.create("urn:first"), OntologyPropertyKind.OBJECT); + var second = property(URI.create("urn:second"), OntologyPropertyKind.OBJECT); + var composed = + property(PROPERTY, OntologyPropertyKind.OBJECT) + .withPropertyChain(List.of(first.getIri(), second.getIri())); + var subject = concept("Subject").withIri(URI.create("urn:subject")); + assertTrue(validate(subject, first, second, composed, cardinality(first.getIri())).getValid()); + assertFalse(validate(subject, first, second, composed, cardinality(PROPERTY)).getValid()); + } + + @Test + void propertyChainsRequireARegularOrderAcrossTheirInverseClosure() { + var first = property(URI.create("urn:first"), OntologyPropertyKind.OBJECT); + var second = property(URI.create("urn:second"), OntologyPropertyKind.OBJECT); + var composed = + property(PROPERTY, OntologyPropertyKind.OBJECT) + .withPropertyChain(List.of(first.getIri(), second.getIri())); + assertTrue(validate(first, second, composed).getValid()); + first.setPropertyChain(List.of(second.getIri(), PROPERTY)); + assertFalse(validate(first, second, composed).getValid()); + first.setPropertyChain(List.of()); + var inverse = + property(INVERSE, OntologyPropertyKind.OBJECT) + .withInverse(PROPERTY) + .withPropertyChain(List.of(first.getIri(), second.getIri())); + first.setPropertyChain(List.of(second.getIri(), INVERSE)); + assertFalse(validate(first, second, composed, inverse).getValid()); + } + + @Test + void chainsPermitTransitivityAndOneRecursiveEndButNotAnInteriorRecursion() { + var first = property(URI.create("urn:first"), OntologyPropertyKind.OBJECT); + var second = property(URI.create("urn:second"), OntologyPropertyKind.OBJECT); + var composed = property(PROPERTY, OntologyPropertyKind.OBJECT); + for (var chain : + List.of( + List.of(PROPERTY, PROPERTY), + List.of(PROPERTY, first.getIri()), + List.of(first.getIri(), PROPERTY))) { + assertTrue(validate(first, composed.withPropertyChain(chain)).getValid()); + } + assertFalse( + validate( + first, + second, + composed.withPropertyChain(List.of(first.getIri(), PROPERTY, second.getIri()))) + .getValid()); + assertFalse( + validate(first, composed.withPropertyChain(List.of(PROPERTY, first.getIri(), PROPERTY))) + .getValid()); + } + + @Test + void inverseChainAndDisjointReferencesRequireCompatibleProperties() { + var object = + property(PROPERTY, OntologyPropertyKind.OBJECT) + .withInverse(INVERSE) + .withPropertyChain(List.of(INVERSE, PROPERTY)) + .withDisjointWith(List.of(INVERSE)); + assertEquals( + 4, validate(object, property(INVERSE, OntologyPropertyKind.DATA)).getViolationCount()); + assertEquals( + 3, + validate(object).getViolations().stream() + .filter( + issue -> + issue.getViolation().getCode() + == OntologyProfileViolationCode.UNDECLARED_PROPERTY) + .count()); + } + + @ParameterizedTest + @EnumSource( + value = RelationshipCharacteristic.class, + names = {"FUNCTIONAL", "INVERSE_FUNCTIONAL", "ASYMMETRIC", "IRREFLEXIVE"}) + void nonSimplePropertiesCannotCarrySimpleOnlyCharacteristics(RelationshipCharacteristic kind) { + var property = + property(PROPERTY, OntologyPropertyKind.OBJECT) + .withCharacteristics(Set.of(RelationshipCharacteristic.TRANSITIVE, kind)); + var report = validate(property); + assertEquals(1, report.getViolationCount()); + assertEquals( + OntologyProfileViolationCode.NON_SIMPLE_PROPERTY, + report.getViolations().getFirst().getViolation().getCode()); + } + + @Test + void unknownPropertiesAndExactIriSpellingAreNotSilentlyAccepted() { + assertEquals( + OntologyProfileViolationCode.UNDECLARED_PROPERTY, + validate(assertion()).getViolations().getFirst().getViolation().getCode()); + var declared = property(URI.create("https://example.org/p%2f"), OntologyPropertyKind.OBJECT); + var assertion = assertion().withPropertyIri(URI.create("https://example.org/p%2F")); + assertFalse(validate(declared, assertion).getValid()); + assertTrue(validate(declared, assertion.withPropertyIri(declared.getIri())).getValid()); + } + + @Test + void vocabularyPropertiesKeepTheirFixedKindsWithoutRedeclarations() { + assertTrue( + validate(assertion().withPropertyIri(URI.create(OWL2.topObjectProperty.getURI()))) + .getValid()); + assertTrue(validate(assertion().withPropertyIri(URI.create(SKOS.related.getURI()))).getValid()); + assertFalse( + validate(assertion().withPropertyIri(URI.create(OWL2.topDataProperty.getURI()))) + .getValid()); + assertFalse( + validate(assertion().withPropertyIri(URI.create(SKOS.prefLabel.getURI()))).getValid()); + assertFalse(validate(assertion().withPropertyIri(URI.create(RDFS.label.getURI()))).getValid()); + } + + @Test + void explicitDeclarationsCannotChangeFixedVocabularyKinds() { + var related = URI.create(SKOS.related.getURI()); + assertTrue(validate(property(related, OntologyPropertyKind.OBJECT)).getValid()); + var report = validate(property(related, OntologyPropertyKind.DATA)); + assertFalse(report.getValid()); + assertEquals( + OntologyProfileViolationCode.ILLEGAL_PUNNING, + report.getViolations().getFirst().getViolation().getCode()); + } + + @Test + void individualIdentityAssertionsDoNotRequirePropertyDeclarations() { + assertTrue(validate(assertion().withPropertyIri(URI.create(OWL2.sameAs.getURI()))).getValid()); + assertTrue( + validate(assertion().withPropertyIri(URI.create(OWL2.differentFrom.getURI()))).getValid()); + assertFalse(validate(cardinality(URI.create(OWL2.sameAs.getURI()))).getValid()); + } + + @Test + void classKindsCannotUseIndividualIdentityAssertions() { + final var customer = concept("Customer"); + final var same = assertion().withPropertyIri(URI.create(OWL2.sameAs.getURI())); + assertFalse(validate(customer, same.withSubjectIri(customer.getIri())).getValid()); + assertFalse( + validate( + customer, + same.withSubjectIri(URI.create("urn:external")).withTargetIri(customer.getIri())) + .getValid()); + assertTrue( + validate(customer.withConceptType(OntologyConceptType.SKOS_CONCEPT), same).getValid()); + } + + @Test + void reportSizeIsBoundedWithoutLosingTheFailureOrTotalCount() { + var members = + IntStream.range(0, 1001).mapToObj(index -> member(RELEASE.getId(), assertion())).toList(); + var report = OntologyStructureValidator.validate(RELEASE, members); + assertFalse(report.getValid()); + assertEquals(1001, report.getViolationCount()); + assertEquals(1000, report.getViolations().size()); + } + + private static OntologyReleaseStructureReport validate(OntologyDefinition... definitions) { + return OntologyStructureValidator.validate( + RELEASE, Arrays.stream(definitions).map(value -> member(RELEASE.getId(), value)).toList()); + } + + private static OntologyReleaseMember member(UUID releaseId, OntologyDefinition definition) { + return OntologyReleaseManifest.member( + releaseId, + switch (definition) { + case OntologyProperty property -> MemberKind.PROPERTY; + case OntologyConcept concept -> MemberKind.CONCEPT; + default -> MemberKind.AXIOM; + }, + definition.getId(), + JsonUtils.getObjectMapper().valueToTree(definition)); + } + + private static OntologyProperty property(URI iri, OntologyPropertyKind kind) { + return new OntologyProperty().withId(UUID.randomUUID()).withIri(iri).withKind(kind); + } + + private static OntologyConcept concept(String name) { + return new OntologyConcept() + .withId(UUID.randomUUID()) + .withIri(URI.create("urn:class:" + name)) + .withConceptType(OntologyConceptType.OWL_CLASS) + .withOntology(new EntityReference().withId(RELEASE.getId()).withType("ontology")); + } + + private static OntologyAxiom subclass(OntologyConcept subject, OntologyConcept... parents) { + return assertion() + .withAxiomType(OntologyAxiomType.SUBCLASS_OF) + .withSubjectIri(subject.getIri()) + .withPropertyIri(null) + .withTargetIri(null) + .withExpressions( + Arrays.stream(parents) + .map( + parent -> + new OntologyExpression() + .withKind(OntologyExpressionKind.NAMED_CLASS) + .withClassIri(parent.getIri())) + .toList()); + } + + private static OntologyAxiom assertion() { + return new OntologyAxiom() + .withId(UUID.randomUUID()) + .withOntology(new EntityReference().withId(UUID.randomUUID()).withType("ontology")) + .withSubjectIri(URI.create("urn:subject")) + .withAxiomType(OntologyAxiomType.OBJECT_PROPERTY_ASSERTION) + .withPropertyIri(PROPERTY) + .withTargetIri(URI.create("urn:target")); + } + + private static OntologyAxiom cardinality(URI property) { + return assertion() + .withAxiomType(OntologyAxiomType.SUBCLASS_OF) + .withPropertyIri(null) + .withTargetIri(null) + .withExpressions( + List.of( + new OntologyExpression() + .withKind(OntologyExpressionKind.RESTRICTION) + .withPropertyIri(property) + .withRestrictionKind(OntologyRestrictionKind.EXACT) + .withCardinality(1))); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OwlProfileGuardTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OwlProfileGuardTest.java index a80a0a199a8c..01f8b5a7e09a 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OwlProfileGuardTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OwlProfileGuardTest.java @@ -13,28 +13,277 @@ package org.openmetadata.service.ontology; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import jakarta.ws.rs.BadRequestException; import java.net.URI; +import java.util.Arrays; import java.util.List; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Stream; +import org.apache.jena.vocabulary.OWL2; +import org.apache.jena.vocabulary.XSD; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; import org.openmetadata.schema.api.data.OntologyProfileReport; import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyProperty; import org.openmetadata.schema.type.OntologyAxiomType; import org.openmetadata.schema.type.OntologyExpression; import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyLiteral; import org.openmetadata.schema.type.OntologyProfileViolationCode; +import org.openmetadata.schema.type.OntologyPropertyKind; import org.openmetadata.schema.type.OntologyRestrictionKind; +import org.openmetadata.schema.type.RelationshipCharacteristic; +import org.openmetadata.schema.utils.JsonUtils; class OwlProfileGuardTest { private static final URI PRODUCT = URI.create("https://example.org/Product"); private static final URI CATEGORY = URI.create("https://example.org/Category"); private static final URI HAS_CATEGORY = URI.create("https://example.org/hasCategory"); + @Test + void everyAxiomRequiresASubject() { + assertFalse( + new OwlProfileGuard(iri -> null) + .validate(classAxiom(namedClass(CATEGORY)).withSubjectIri(null)) + .getValid()); + } + + @Test + void ignoresNoExpressionFieldsAndBoundsNestedInput() { + final var guard = guard(property -> true); + assertFalse(guard.validate(classAxiom(namedClass(CATEGORY).withCardinality(2))).getValid()); + OntologyExpression nested = namedClass(CATEGORY); + for (int depth = 0; depth < 80; depth++) { + nested = + new OntologyExpression() + .withKind(OntologyExpressionKind.RESTRICTION) + .withPropertyIri(HAS_CATEGORY) + .withRestrictionKind(OntologyRestrictionKind.SOME) + .withFiller(nested); + } + assertFalse(guard.validate(classAxiom(nested)).getValid()); + } + + @Test + void dataLiteralsRequireSupportedDatatypesAndValidLexicalValues() { + final var guard = + new OwlProfileGuard(iri -> new OntologyProperty().withKind(OntologyPropertyKind.DATA)); + final var axiom = + new OntologyAxiom() + .withAxiomType(OntologyAxiomType.DATA_PROPERTY_ASSERTION) + .withSubjectIri(PRODUCT) + .withPropertyIri(HAS_CATEGORY); + for (var literal : + List.of( + new OntologyLiteral(), + new OntologyLiteral().withValue("no").withDatatypeIri(URI.create(XSD.integer.getURI())), + new OntologyLiteral().withValue("value").withDatatypeIri(URI.create("urn:unknown")), + new OntologyLiteral() + .withValue("2026-10-15") + .withDatatypeIri(URI.create(XSD.date.getURI())))) { + assertFalse(guard.validate(axiom.withLiteral(literal)).getValid()); + } + assertTrue(guard.validate(axiom.withLiteral(literal())).getValid()); + assertFalse( + guard + .validate(axiom.withPropertyIri(URI.create(OWL2.topDataProperty.getURI()))) + .getValid()); + } + + @Test + void dataRangesAcceptBooleanDatatypesButRejectClassExpressions() { + final var guard = + new OwlProfileGuard(iri -> new OntologyProperty().withKind(OntologyPropertyKind.DATA)); + final var union = + new OntologyExpression() + .withKind(OntologyExpressionKind.UNION) + .withOperands( + List.of( + namedClass(URI.create(XSD.xstring.getURI())), + namedClass(URI.create(XSD.integer.getURI())))); + final var restriction = exactlyOneCategory().withFiller(union); + assertTrue(guard.validate(classAxiom(restriction)).getValid()); + union.setOperands(List.of(namedClass(CATEGORY), namedClass(URI.create(XSD.integer.getURI())))); + assertFalse(guard.validate(classAxiom(restriction)).getValid()); + } + + @ParameterizedTest + @EnumSource(OntologyPropertyKind.class) + void assertionsRespectDeclaredPropertyKinds(OntologyPropertyKind kind) { + OwlProfileGuard guard = new OwlProfileGuard(iri -> new OntologyProperty().withKind(kind)); + OntologyAxiom assertion = + new OntologyAxiom() + .withSubjectIri(PRODUCT) + .withPropertyIri(HAS_CATEGORY) + .withTargetIri(CATEGORY) + .withAxiomType(OntologyAxiomType.OBJECT_PROPERTY_ASSERTION); + assertEquals(kind == OntologyPropertyKind.OBJECT, guard.validate(assertion).getValid()); + assertion + .withAxiomType(OntologyAxiomType.DATA_PROPERTY_ASSERTION) + .withTargetIri(null) + .withLiteral(literal()); + assertEquals(kind == OntologyPropertyKind.DATA, guard.validate(assertion).getValid()); + } + + @Test + void restrictionFillersRespectTheDeclaredPropertyKind() { + OwlProfileGuard guard = + new OwlProfileGuard(iri -> new OntologyProperty().withKind(OntologyPropertyKind.DATA)); + assertFalse(guard.validate(classAxiom(exactlyOneCategory())).getValid()); + OntologyExpression expression = + exactlyOneCategory() + .withFiller(namedClass(URI.create("http://www.w3.org/2001/XMLSchema#string"))); + assertTrue(guard.validate(classAxiom(expression)).getValid()); + expression + .withRestrictionKind(OntologyRestrictionKind.VALUE) + .withFiller(null) + .withLiteral(literal()) + .withCardinality(null); + assertTrue(guard.validate(classAxiom(expression)).getValid()); + } + + @ParameterizedTest + @EnumSource(OntologyAxiomType.class) + void acceptsEverySupportedAxiomShape(OntologyAxiomType type) { + OntologyAxiom axiom = classAxiom(namedClass(CATEGORY)).withAxiomType(type); + if (type == OntologyAxiomType.OBJECT_PROPERTY_ASSERTION) { + axiom.withExpressions(List.of()).withPropertyIri(HAS_CATEGORY).withTargetIri(CATEGORY); + } else if (type == OntologyAxiomType.DATA_PROPERTY_ASSERTION) { + axiom.withExpressions(List.of()).withPropertyIri(HAS_CATEGORY).withLiteral(literal()); + } + assertTrue(guard(property -> true).validate(axiom).getValid()); + } + + @ParameterizedTest + @EnumSource(OntologyRestrictionKind.class) + void acceptsEverySupportedRestriction(OntologyRestrictionKind kind) { + OntologyExpression expression = exactlyOneCategory().withRestrictionKind(kind); + if (kind == OntologyRestrictionKind.SOME || kind == OntologyRestrictionKind.ONLY) { + expression.setCardinality(null); + } + if (kind == OntologyRestrictionKind.VALUE) { + expression.withFiller(null).withCardinality(null).withIndividualIri(CATEGORY); + } + assertTrue(guard(property -> true).validate(classAxiom(expression)).getValid()); + } + + @ParameterizedTest + @EnumSource( + value = OntologyExpressionKind.class, + names = {"INTERSECTION", "UNION"}) + void acceptsNestedBooleanExpressions(OntologyExpressionKind kind) { + OntologyExpression expression = + new OntologyExpression() + .withKind(kind) + .withOperands(List.of(namedClass(CATEGORY), exactlyOneCategory())); + assertTrue(guard(property -> true).validate(classAxiom(expression)).getValid()); + } + + @ParameterizedTest + @MethodSource("invalidAxioms") + void rejectsIncompleteOrConflictingShapes(OntologyAxiom axiom) { + OwlProfileGuard guard = guard(property -> true); + assertFalse(guard.validate(axiom).getValid()); + assertThrows(BadRequestException.class, () -> guard.validateOrThrow(axiom)); + } + + static Stream invalidAxioms() { + return Stream.of( + classAxiom(namedClass(CATEGORY)).withAxiomType(null), + classAxiom(namedClass(CATEGORY)).withExpressions(List.of()), + classAxiom(namedClass(CATEGORY)).withPropertyIri(HAS_CATEGORY), + classAxiom(namedClass(CATEGORY)).withLiteral(literal()), + classAxiom(namedClass(CATEGORY)) + .withAxiomType(OntologyAxiomType.CLASS_ASSERTION) + .withExpressions(List.of(namedClass(CATEGORY), namedClass(PRODUCT))), + classAxiom(namedClass(CATEGORY)).withAxiomType(OntologyAxiomType.OBJECT_PROPERTY_ASSERTION), + new OntologyAxiom() + .withSubjectIri(PRODUCT) + .withAxiomType(OntologyAxiomType.OBJECT_PROPERTY_ASSERTION) + .withPropertyIri(HAS_CATEGORY) + .withTargetIri(CATEGORY) + .withLiteral(literal()), + classAxiom(namedClass(CATEGORY)).withAxiomType(OntologyAxiomType.DATA_PROPERTY_ASSERTION), + new OntologyAxiom() + .withSubjectIri(PRODUCT) + .withAxiomType(OntologyAxiomType.DATA_PROPERTY_ASSERTION) + .withPropertyIri(HAS_CATEGORY) + .withLiteral(literal()) + .withTargetIri(CATEGORY), + classAxiom(new OntologyExpression()), + classAxiom(namedClass(null)), + classAxiom( + new OntologyExpression() + .withKind(OntologyExpressionKind.INTERSECTION) + .withOperands(List.of(namedClass(CATEGORY)))), + classAxiom(new OntologyExpression().withKind(OntologyExpressionKind.ONE_OF)), + classAxiom(new OntologyExpression().withKind(OntologyExpressionKind.RESTRICTION)), + classAxiom( + exactlyOneCategory() + .withRestrictionKind(OntologyRestrictionKind.SOME) + .withFiller(null)), + classAxiom(exactlyOneCategory().withCardinality(null)), + classAxiom(exactlyOneCategory().withCardinality(-1)), + classAxiom( + exactlyOneCategory() + .withRestrictionKind(OntologyRestrictionKind.VALUE) + .withLiteral(literal()) + .withIndividualIri(CATEGORY)), + classAxiom(namedClass(CATEGORY)).withExpressions(Arrays.asList((OntologyExpression) null))); + } + + @Test + void acceptsClassIndividualReuseInsideNestedExpressions() { + OwlProfileGuard guard = guard(property -> true); + assertTrue(guard.validate(classAxiom(exactlyOneCategory())).getValid()); + assertTrue( + guard + .validate( + classAxiom(namedClass(CATEGORY)).withAxiomType(OntologyAxiomType.CLASS_ASSERTION)) + .getValid()); + } + + private static OntologyLiteral literal() { + return new OntologyLiteral() + .withValue("value") + .withDatatypeIri(URI.create("http://www.w3.org/2001/XMLSchema#string")); + } + + @Test + void nestedReferencesMustBeAbsolute() { + OntologyExpression restriction = + exactlyOneCategory().withFiller(namedClass(URI.create("relative/Category"))); + OntologyProfileReport report = guard(property -> true).validate(classAxiom(restriction)); + assertFalse(report.getValid()); + assertEquals("/expressions/0/filler/classIri", report.getViolations().getFirst().getPath()); + } + + @Test + void enumerationPreservesDistinctSemanticIdentifiers() { + List iris = List.of("HTTPS://EXAMPLE.org/客户%2fA", "https://example.org/客户%2fA"); + OntologyExpression expression = + JsonUtils.readValue( + "{\"kind\":\"ONE_OF\",\"individualIris\":" + JsonUtils.pojoToJson(iris) + "}", + OntologyExpression.class); + assertEquals(iris, expression.getIndividualIris().stream().map(URI::toString).toList()); + OwlProfileGuard guard = guard(property -> true); + assertTrue(guard.validate(classAxiom(expression)).getValid()); + expression.setIndividualIris(List.of(URI.create(iris.getFirst()), URI.create(iris.getFirst()))); + assertFalse(guard.validate(classAxiom(expression)).getValid()); + } + @Test void acceptsQualifiedCardinalityOnSimpleProperty() { - OwlProfileGuard guard = guard(propertyIri -> true, emptyVocabulary()); + OwlProfileGuard guard = guard(propertyIri -> true); OntologyAxiom axiom = classAxiom(exactlyOneCategory()); OntologyProfileReport report = guard.validate(axiom); @@ -45,8 +294,7 @@ void acceptsQualifiedCardinalityOnSimpleProperty() { @Test void rejectsCardinalityOnTransitiveProperty() { - OwlProfileGuard guard = - guard(propertyIri -> !HAS_CATEGORY.equals(propertyIri), emptyVocabulary()); + OwlProfileGuard guard = guard(propertyIri -> !HAS_CATEGORY.equals(propertyIri)); OntologyProfileReport report = guard.validate(classAxiom(exactlyOneCategory())); @@ -66,8 +314,7 @@ void rejectsIncompleteValueRestriction() { .withPropertyIri(HAS_CATEGORY) .withRestrictionKind(OntologyRestrictionKind.VALUE); - OntologyProfileReport report = - guard(propertyIri -> true, emptyVocabulary()).validate(classAxiom(restriction)); + OntologyProfileReport report = guard(propertyIri -> true).validate(classAxiom(restriction)); assertFalse(report.getValid()); } @@ -78,7 +325,7 @@ void rejectsReservedVocabularyRedefinition() { classAxiom(namedClass(CATEGORY)) .withSubjectIri(URI.create("http://www.w3.org/2002/07/owl#Thing")); - OntologyProfileReport report = guard(propertyIri -> true, emptyVocabulary()).validate(axiom); + OntologyProfileReport report = guard(propertyIri -> true).validate(axiom); assertFalse(report.getValid()); assertTrue( @@ -89,30 +336,23 @@ void rejectsReservedVocabularyRedefinition() { } @Test - void rejectsClassIndividualPunning() { - OwlProfileGuard.VocabularyIndex vocabulary = - new OwlProfileGuard.VocabularyIndex() { - @Override - public boolean isClass(final URI iri) { - return false; - } - - @Override - public boolean isIndividual(final URI iri) { - return PRODUCT.equals(iri); - } - }; - + void acceptsClassIndividualPunning() { OntologyProfileReport report = - guard(propertyIri -> true, vocabulary).validate(classAxiom(namedClass(CATEGORY))); + guard(property -> true) + .validate( + classAxiom(namedClass(CATEGORY)).withAxiomType(OntologyAxiomType.CLASS_ASSERTION)); - assertFalse(report.getValid()); + assertTrue(report.getValid()); } - private static OwlProfileGuard guard( - final OwlProfileGuard.PropertySimplicity properties, - final OwlProfileGuard.VocabularyIndex vocabulary) { - return new OwlProfileGuard(properties, vocabulary); + private static OwlProfileGuard guard(final Predicate isSimple) { + return new OwlProfileGuard( + iri -> + isSimple.test(iri) + ? null + : new OntologyProperty() + .withKind(OntologyPropertyKind.OBJECT) + .withCharacteristics(Set.of(RelationshipCharacteristic.TRANSITIVE))); } private static OntologyAxiom classAxiom(final OntologyExpression expression) { @@ -136,18 +376,4 @@ private static OntologyExpression namedClass(final URI classIri) { .withKind(OntologyExpressionKind.NAMED_CLASS) .withClassIri(classIri); } - - private static OwlProfileGuard.VocabularyIndex emptyVocabulary() { - return new OwlProfileGuard.VocabularyIndex() { - @Override - public boolean isClass(final URI iri) { - return false; - } - - @Override - public boolean isIndividual(final URI iri) { - return false; - } - }; - } } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/rdf/OntologyExpressionRdfWriterTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/rdf/OntologyExpressionRdfWriterTest.java index 2a271659f551..4f4405550017 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/rdf/OntologyExpressionRdfWriterTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/rdf/OntologyExpressionRdfWriterTest.java @@ -87,6 +87,24 @@ class OntologyExpressionRdfWriterTest { private final OntologyExpressionRdfWriter writer = new OntologyExpressionRdfWriter(iri -> iri.equals(CUSTOMER_CODE.getIRI().toString())); + @Test + void classAndIndividualReuseRetainsBothMeaningsInOwl2Dl() { + final Model model = ModelFactory.createDefaultModel(); + try { + writer.write(model, classAxiom(OntologyAxiomType.SUBCLASS_OF, named(PARTY))); + writer.write(model, classAxiom(OntologyAxiomType.CLASS_ASSERTION, named(PERSON))); + final OWLOntology ontology = OwlApiProjection.parse(model); + assertEquals(List.of(), OwlApiProjection.dlViolations(ontology)); + assertEquals( + Set.of( + OWL.getOWLSubClassOfAxiom(CUSTOMER, PARTY), + OWL.getOWLClassAssertionAxiom(PERSON, OWL.getOWLNamedIndividual(CUSTOMER.getIRI()))), + ontology.logicalAxioms().collect(Collectors.toSet())); + } finally { + model.close(); + } + } + @ParameterizedTest(name = "{0}") @MethodSource("constructs") void roundTripsThroughOwlApiAsDlValidOwl( @@ -148,7 +166,7 @@ private static Stream booleanExpressions() { OntologyAxiomType.EQUIVALENT_CLASS, new OntologyExpression() .withKind(OntologyExpressionKind.ONE_OF) - .withIndividualIris(Set.of(iri(ACTIVE), iri(INACTIVE)))), + .withIndividualIris(List.of(iri(ACTIVE), iri(INACTIVE)))), OWL.getOWLEquivalentClassesAxiom(CUSTOMER, OWL.getOWLObjectOneOf(ACTIVE, INACTIVE))), construct( "nested expression inside a restriction filler", @@ -198,6 +216,24 @@ private static Stream objectRestrictions() { private static Stream dataRestrictions() { return Stream.of( + construct( + "data union", + classAxiom( + OntologyAxiomType.SUBCLASS_OF, + some(CUSTOMER_CODE, union(datatype(XSD_STRING), datatype(XSD_INTEGER)))), + OWL.getOWLSubClassOfAxiom( + CUSTOMER, + OWL.getOWLDataSomeValuesFrom( + CUSTOMER_CODE, OWL.getOWLDataUnionOf(XSD_STRING, XSD_INTEGER)))), + construct( + "data intersection", + classAxiom( + OntologyAxiomType.SUBCLASS_OF, + only(CUSTOMER_CODE, intersection(datatype(XSD_STRING), datatype(XSD_INTEGER)))), + OWL.getOWLSubClassOfAxiom( + CUSTOMER, + OWL.getOWLDataAllValuesFrom( + CUSTOMER_CODE, OWL.getOWLDataIntersectionOf(XSD_STRING, XSD_INTEGER)))), construct( "data has-value literal", classAxiom( diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/rdf/OntologyReleaseProjectionTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/rdf/OntologyReleaseProjectionTest.java new file mode 100644 index 000000000000..1ebba21fb58b --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/rdf/OntologyReleaseProjectionTest.java @@ -0,0 +1,606 @@ +package org.openmetadata.service.rdf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.RDFList; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.OWL2; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import org.apache.jena.vocabulary.SKOS; +import org.apache.jena.vocabulary.XSD; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.api.data.ConceptMapping; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyAxiom; +import org.openmetadata.schema.entity.data.OntologyConcept; +import org.openmetadata.schema.entity.data.OntologyProperty; +import org.openmetadata.schema.entity.data.OntologyRelease; +import org.openmetadata.schema.type.OntologyAxiomType; +import org.openmetadata.schema.type.OntologyConceptType; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.OntologyLiteral; +import org.openmetadata.schema.type.OntologyPrefix; +import org.openmetadata.schema.type.OntologyPropertyKind; +import org.openmetadata.schema.type.OntologyReleaseMember; +import org.openmetadata.schema.type.OntologyReleaseMember.MemberKind; +import org.openmetadata.schema.type.OntologyRestrictionKind; +import org.openmetadata.schema.type.RelationshipCharacteristic; +import org.openmetadata.schema.utils.JsonUtils; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.ontology.OntologyReleaseManifest; +import org.openmetadata.service.ontology.OntologyStructureValidator; + +class OntologyReleaseProjectionTest { + private static final String EX = "https://example.org/model/"; + private static final Ontology ROOT = + new Ontology() + .withId(UUID.randomUUID()) + .withName("model") + .withFullyQualifiedName("model") + .withIri(URI.create(EX)) + .withNamespace(URI.create(EX)) + .withPrefixes( + List.of(new OntologyPrefix().withPrefix("ex").withNamespace(URI.create(EX)))); + + @ParameterizedTest + @EnumSource(OntologyConceptType.class) + void retainsExplicitConceptKindsAndLabels(OntologyConceptType type) { + var concept = + concept("Customer") + .withConceptType(type) + .withDisplayName("Customer label") + .withDescription("An organizational customer") + .withSynonyms(Set.of("Client")); + Model model = projection(concept); + try { + var customer = model.createResource(EX + "Customer"); + assertEquals( + type != OntologyConceptType.SKOS_CONCEPT, model.contains(customer, RDF.type, OWL2.Class)); + assertEquals( + type != OntologyConceptType.OWL_CLASS, model.contains(customer, RDF.type, SKOS.Concept)); + assertTrue(model.contains(customer, RDFS.label, "Customer label")); + assertTrue(model.contains(customer, SKOS.altLabel, "Client")); + assertTrue(model.contains(customer, RDFS.comment, "An organizational customer")); + assertEquals(EX, model.getNsPrefixURI("ex")); + assertEquals(List.of(), OwlApiProjection.dlViolations(OwlApiProjection.parse(model))); + } finally { + model.close(); + } + } + + @ParameterizedTest + @EnumSource(OntologyPropertyKind.class) + void propertiesKeepTheirKindsAndDomainRangeMeaning(OntologyPropertyKind kind) { + URI range = URI.create(kind == OntologyPropertyKind.DATA ? XSD.xstring.getURI() : EX + "Party"); + var property = + property("value") + .withKind(kind) + .withDomain(List.of(URI.create(EX + "Customer"))) + .withRange(List.of(range)); + Model model = projection(property); + try { + var expected = + switch (kind) { + case OBJECT -> OWL2.ObjectProperty; + case DATA -> OWL2.DatatypeProperty; + case ANNOTATION -> OWL2.AnnotationProperty; + }; + var value = model.createResource(EX + "value"); + assertTrue(model.contains(value, RDF.type, expected)); + assertTrue(model.contains(value, RDFS.range, model.createResource(range.toString()))); + assertFalse(model.contains(model.createResource(XSD.xstring.getURI()), RDF.type, OWL2.Class)); + assertEquals(List.of(), OwlApiProjection.dlViolations(OwlApiProjection.parse(model))); + } finally { + model.close(); + } + } + + @ParameterizedTest + @EnumSource(RelationshipCharacteristic.class) + void retainsPropertyCharacteristics(RelationshipCharacteristic characteristic) { + Model model = projection(property("knows").withCharacteristics(Set.of(characteristic))); + try { + assertEquals(1, OwlApiProjection.parse(model).getLogicalAxiomCount()); + assertEquals(List.of(), OwlApiProjection.dlViolations(OwlApiProjection.parse(model))); + } finally { + model.close(); + } + } + + @ParameterizedTest + @MethodSource("enumeratedValues") + void dataEnumerationsRetainTypedValuesAndIdentifierIntent(final List values) { + final OntologyProperty definition = + property("code") + .withKind(OntologyPropertyKind.DATA) + .withEnumValues(values) + .withUnit("kg") + .withIsIdentifier(true); + final Model model = projection(definition); + try { + final var property = model.createResource(EX + "code"); + final var range = model.getProperty(property, RDFS.range).getResource(); + assertTrue(model.contains(range, RDF.type, RDFS.Datatype)); + assertTrue(range.getURI().endsWith(":PROPERTY:" + definition.getId() + ":values")); + final var projected = + range + .getProperty(OWL2.equivalentClass) + .getResource() + .getProperty(OWL2.oneOf) + .getResource() + .as(RDFList.class) + .asJavaList(); + assertEquals( + values.stream().map(OntologyLiteral::getValue).toList(), + projected.stream().map(node -> node.asLiteral().getLexicalForm()).toList()); + assertEquals( + values.stream() + .map( + value -> + value.getDatatypeIri() == null + ? XSD.xstring.getURI() + : value.getDatatypeIri().toString()) + .toList(), + projected.stream().map(node -> node.asLiteral().getDatatypeURI()).toList()); + assertTrue( + model.contains( + property, + model.createProperty("https://open-metadata.org/ontology/unitAnnotation"), + "kg")); + assertTrue( + model.contains( + property, + model.createProperty("https://open-metadata.org/ontology/identifierIntent"), + model.createTypedLiteral(true))); + assertFalse(model.contains(property, RDF.type, OWL2.FunctionalProperty)); + assertFalse(model.contains(null, OWL2.hasKey)); + assertEquals(List.of(), OwlApiProjection.dlViolations(OwlApiProjection.parse(model))); + } finally { + model.close(); + } + } + + private static Stream> enumeratedValues() { + return Stream.of( + List.of(new OntologyLiteral().withValue(""), new OntologyLiteral().withValue("Gold")), + List.of( + new OntologyLiteral().withValue("01").withDatatypeIri(URI.create(XSD.integer.getURI())), + new OntologyLiteral().withValue("2").withDatatypeIri(URI.create(XSD.integer.getURI()))), + List.of( + new OntologyLiteral() + .withValue("2026-10-15T00:00:00Z") + .withDatatypeIri(URI.create(XSD.dateTime.getURI())))); + } + + @Test + void retainsOrderedChainsInversesAndDisjointProperties() { + Model model = + projection( + property("related") + .withInverse(URI.create(EX + "inverse")) + .withPropertyChain(List.of(URI.create(EX + "first"), URI.create(EX + "second"))), + property("different").withDisjointWith(List.of(URI.create(EX + "disjoint")))); + try { + var ontology = OwlApiProjection.parse(model); + assertEquals(3, ontology.getLogicalAxiomCount()); + assertTrue( + model.contains( + model.createResource(EX + "related"), + OWL2.inverseOf, + model.createResource(EX + "inverse"))); + assertEquals( + List.of(EX + "first", EX + "second"), + model + .getProperty(model.createResource(EX + "related"), OWL2.propertyChainAxiom) + .getResource() + .as(org.apache.jena.rdf.model.RDFList.class) + .asJavaList() + .stream() + .map(node -> node.asResource().getURI()) + .toList()); + assertEquals(List.of(), OwlApiProjection.dlViolations(ontology)); + } finally { + model.close(); + } + } + + @ParameterizedTest + @EnumSource(ConceptMapping.ConceptMappingType.class) + void retainsEveryExplicitMappingKind(ConceptMapping.ConceptMappingType type) { + Model model = + projection( + concept("Customer") + .withConceptType(OntologyConceptType.BOTH) + .withConceptMappings( + List.of( + new ConceptMapping() + .withMappingType(type) + .withConceptIri(URI.create(EX + "external"))))); + try { + var ontology = OwlApiProjection.parse(model); + assertTrue( + model + .listStatements( + model.createResource(EX + "Customer"), + null, + model.createResource(EX + "external")) + .hasNext()); + assertEquals(List.of(), OwlApiProjection.dlViolations(ontology)); + } finally { + model.close(); + } + } + + @Test + void aKnownDataPropertyKeepsUnqualifiedCardinalityAndCustomDatatypeFillers() { + var restriction = + new OntologyExpression() + .withKind(OntologyExpressionKind.RESTRICTION) + .withPropertyIri(URI.create(EX + "code")) + .withRestrictionKind(OntologyRestrictionKind.EXACT) + .withCardinality(1); + var axiom = axiom("restriction").withExpressions(List.of(restriction)); + Model unqualified = projection(property("code").withKind(OntologyPropertyKind.DATA), axiom); + try { + assertTrue(modelHasDataCardinality(unqualified)); + } finally { + unqualified.close(); + } + restriction.setFiller( + new OntologyExpression() + .withKind(OntologyExpressionKind.NAMED_CLASS) + .withClassIri(URI.create(EX + "Code"))); + Model qualified = projection(property("code").withKind(OntologyPropertyKind.DATA), axiom); + try { + assertTrue(qualified.contains(null, OWL2.onDataRange, qualified.createResource(EX + "Code"))); + assertTrue( + qualified.contains(qualified.createResource(EX + "Code"), RDF.type, RDFS.Datatype)); + assertFalse(qualified.contains(qualified.createResource(EX + "Code"), RDF.type, OWL2.Class)); + assertTrue(modelHasDataCardinality(qualified)); + } finally { + qualified.close(); + } + } + + @Test + void everyAuthoredAxiomHasItsFrozenSourceAndDuplicateMeaningKeepsBothSources() { + var first = axiom("first"); + var second = axiom("second"); + Bundle bundle = bundle(List.of(first, second)); + Model model = new OntologyReleaseProjection().build(bundle.release(), bundle.members()); + try { + var ontology = OwlApiProjection.parse(model); + var subclassAxioms = + ontology.axioms(org.semanticweb.owlapi.model.AxiomType.SUBCLASS_OF).toList(); + assertEquals(2, subclassAxioms.size()); + assertEquals( + Set.of( + source(bundle.release(), MemberKind.AXIOM, first.getId()), + source(bundle.release(), MemberKind.AXIOM, second.getId())), + subclassAxioms.stream() + .flatMap(axiom -> axiom.annotations()) + .map(annotation -> annotation.getValue().toString()) + .collect(java.util.stream.Collectors.toSet())); + assertTrue(model.contains(null, OWL2.annotatedProperty, RDFS.subClassOf)); + assertTrue(model.contains(DCTerms.source, RDF.type, OWL2.AnnotationProperty)); + assertEquals(List.of(), OwlApiProjection.dlViolations(ontology)); + } finally { + model.close(); + } + } + + @ParameterizedTest + @EnumSource(OntologyAxiomType.class) + void everyAxiomKindRetainsItsLogicalMeaningAndProvenance(OntologyAxiomType kind) { + var axiom = axiom("meaning").withAxiomType(kind); + switch (kind) { + case OBJECT_PROPERTY_ASSERTION -> axiom + .withExpressions(List.of()) + .withPropertyIri(URI.create(EX + "knows")) + .withTargetIri(URI.create(EX + "person")); + case DATA_PROPERTY_ASSERTION -> axiom + .withExpressions(List.of()) + .withPropertyIri(URI.create(EX + "age")) + .withLiteral( + new OntologyLiteral() + .withValue("7") + .withDatatypeIri(URI.create(XSD.integer.getURI()))); + default -> {} + } + Model model = projection(axiom); + try { + var parsed = OwlApiProjection.parse(model); + assertEquals(1, parsed.getLogicalAxiomCount()); + assertEquals(1L, parsed.logicalAxioms().flatMap(value -> value.annotations()).count()); + assertEquals(List.of(), OwlApiProjection.dlViolations(parsed)); + } finally { + model.close(); + } + } + + @Test + void dataPropertiesRetainFunctionalAndDisjointSemantics() { + Model model = + projection( + property("code") + .withKind(OntologyPropertyKind.DATA) + .withCharacteristics(Set.of(RelationshipCharacteristic.FUNCTIONAL)) + .withDisjointWith(List.of(URI.create(EX + "otherCode")))); + try { + var parsed = OwlApiProjection.parse(model); + assertEquals(2, parsed.getLogicalAxiomCount()); + assertTrue( + model.contains(model.createResource(EX + "otherCode"), RDF.type, OWL2.DatatypeProperty)); + assertEquals(List.of(), OwlApiProjection.dlViolations(parsed)); + } finally { + model.close(); + } + } + + @Test + void exportPreservesAnInvalidCandidateForValidationInsteadOfDroppingItsAxioms() { + Model model = + projection( + property("related") + .withDisjointWith(List.of(URI.create(EX + "different"))) + .withPropertyChain(List.of(URI.create(EX + "first"), URI.create(EX + "second")))); + try { + var parsed = OwlApiProjection.parse(model); + assertEquals(2, parsed.getLogicalAxiomCount()); + assertFalse(OwlApiProjection.dlViolations(parsed).isEmpty()); + } finally { + model.close(); + } + } + + @ParameterizedTest + @MethodSource("regularChains") + void acceptedPropertyChainsAlsoPassTheIndependentOwl2DlProfile( + List properties) { + final var bundle = bundle(properties); + assertTrue(OntologyStructureValidator.validate(bundle.release(), bundle.members()).getValid()); + final var model = new OntologyReleaseProjection().build(bundle.release(), bundle.members()); + try { + assertEquals(List.of(), OwlApiProjection.dlViolations(OwlApiProjection.parse(model))); + } finally { + model.close(); + } + } + + static Stream> regularChains() { + final URI first = URI.create(EX + "first"); + final URI second = URI.create(EX + "second"); + final URI related = URI.create(EX + "related"); + return Stream.of( + List.of( + property("related").withPropertyChain(List.of(first, second)), + property("first"), + property("second")), + List.of(property("related").withPropertyChain(List.of(related, related))), + List.of(property("related").withPropertyChain(List.of(related, first)), property("first")), + List.of(property("related").withPropertyChain(List.of(first, related)), property("first")), + List.of( + property("related") + .withPropertyChain(List.of(first, second)) + .withInverse(URI.create(EX + "inverse")), + property("inverse"), + property("first"), + property("second"))); + } + + @Test + void rejectsAValidDigestWhenPayloadIdentityOrOwnershipIsWrong() { + Bundle bundle = bundle(List.of(concept("Customer"))); + var member = bundle.members().getLast(); + member.setMemberId(UUID.randomUUID()); + reseal(bundle); + assertThrows( + BadRequestException.class, + () -> new OntologyReleaseProjection().build(bundle.release(), bundle.members())); + + Bundle foreign = + bundle( + List.of( + concept("Customer") + .withOntology( + new org.openmetadata.schema.type.EntityReference() + .withId(UUID.randomUUID()) + .withType("ontology")))); + assertThrows( + BadRequestException.class, + () -> new OntologyReleaseProjection().build(foreign.release(), foreign.members())); + } + + @Test + void unsupportedMembersAndMissingRootsCannotDisappearFromExport() { + Bundle bundle = bundle(List.of(concept("Customer"))); + bundle.members().getLast().setMemberKind(MemberKind.SHAPE); + reseal(bundle); + assertThrows( + BadRequestException.class, + () -> new OntologyReleaseProjection().build(bundle.release(), bundle.members())); + bundle.members().getLast().setMemberKind(MemberKind.CONCEPT); + bundle.members().removeFirst(); + reseal(bundle); + assertThrows( + BadRequestException.class, + () -> new OntologyReleaseProjection().build(bundle.release(), bundle.members())); + } + + @Test + void projectionIsIndependentOfMemberOrderAndRetainsExactIris() { + Bundle bundle = + bundle( + List.of( + concept("one").withIri(URI.create("HTTPS://EXAMPLE.org/Customer")), + concept("two").withIri(URI.create("https://example.org/Customer")))); + Model first = new OntologyReleaseProjection().build(bundle.release(), bundle.members()); + List reversed = new ArrayList<>(bundle.members()); + Collections.reverse(reversed); + Model second = new OntologyReleaseProjection().build(bundle.release(), reversed); + try { + assertTrue(first.isIsomorphicWith(second)); + assertTrue( + first.contains( + first.createResource("HTTPS://EXAMPLE.org/Customer"), RDF.type, OWL2.Class)); + assertTrue( + first.contains( + first.createResource("https://example.org/Customer"), RDF.type, OWL2.Class)); + } finally { + first.close(); + second.close(); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidBundles") + void refusesCorruptOrUnsupportedBundles(String reason, Consumer corrupt) { + Bundle bundle = bundle(List.of(concept("Customer"))); + corrupt.accept(bundle); + assertThrows( + BadRequestException.class, + () -> new OntologyReleaseProjection().build(bundle.release(), bundle.members()), + reason); + } + + static Stream invalidBundles() { + return Stream.of( + invalid("unsealed", bundle -> bundle.release().setSealedAt(null)), + invalid("wrong encoding", bundle -> bundle.release().setEncodingVersion(null)), + invalid("wrong digest", bundle -> bundle.release().setContentDigest("0".repeat(64))), + invalid("wrong count", bundle -> bundle.release().setMemberCount(9)), + invalid("wrong bytes", bundle -> bundle.release().setTotalBytes(1L)), + invalid("wrong release", bundle -> bundle.release().setId(UUID.randomUUID())), + invalid( + "wrong ontology", bundle -> bundle.release().getOntology().setId(UUID.randomUUID())), + invalid( + "wrong ontology IRI", + bundle -> bundle.release().setOntologyIri(URI.create("urn:other"))), + invalid( + "wrong version IRI", bundle -> bundle.release().setVersionIri(URI.create("urn:other"))), + invalid("missing root", bundle -> bundle.members().removeFirst()), + invalid( + "changed member", + bundle -> + ((com.fasterxml.jackson.databind.node.ObjectNode) + bundle.members().getLast().getPayload()) + .put("description", "tampered"))); + } + + private static Arguments invalid(String reason, Consumer corrupt) { + return Arguments.of(reason, corrupt); + } + + private static boolean modelHasDataCardinality(Model model) { + return OwlApiProjection.parse(model) + .logicalAxioms() + .anyMatch(axiom -> axiom.toString().contains("DataExactCardinality")); + } + + private static Model projection(EntityInterface... definitions) { + Bundle bundle = bundle(List.of(definitions)); + return new OntologyReleaseProjection().build(bundle.release(), bundle.members()); + } + + private static OntologyConcept concept(String name) { + return new OntologyConcept() + .withId(UUID.randomUUID()) + .withName(name) + .withIri(URI.create(EX + name)) + .withOntology(ROOT.getEntityReference()) + .withConceptType(OntologyConceptType.OWL_CLASS); + } + + private static OntologyProperty property(String name) { + return new OntologyProperty() + .withId(UUID.randomUUID()) + .withName(name) + .withIri(URI.create(EX + name)) + .withOntology(ROOT.getEntityReference()) + .withKind(OntologyPropertyKind.OBJECT); + } + + private static OntologyAxiom axiom(String name) { + return new OntologyAxiom() + .withId(UUID.randomUUID()) + .withName(name) + .withOntology(ROOT.getEntityReference()) + .withAxiomType(OntologyAxiomType.SUBCLASS_OF) + .withSubjectIri(URI.create(EX + "Customer")) + .withExpressions( + List.of( + new OntologyExpression() + .withKind(OntologyExpressionKind.NAMED_CLASS) + .withClassIri(URI.create(EX + "Party")))); + } + + private static Bundle bundle(List> definitions) { + UUID id = UUID.randomUUID(); + var members = new ArrayList(); + members.add(member(id, MemberKind.ONTOLOGY, ROOT)); + for (var entity : definitions) { + MemberKind kind = + switch (entity) { + case OntologyConcept ignored -> MemberKind.CONCEPT; + case OntologyProperty ignored -> MemberKind.PROPERTY; + case OntologyAxiom ignored -> MemberKind.AXIOM; + default -> throw new IllegalArgumentException(); + }; + members.add(member(id, kind, entity)); + } + var release = + new OntologyRelease() + .withId(id) + .withOntology(ROOT.getEntityReference()) + .withOntologyIri(ROOT.getIri()) + .withVersionIri(URI.create("urn:openmetadata:ontology-release:" + id)) + .withSealedAt(1L) + .withEncodingVersion( + OntologyRelease.EncodingVersion.fromValue(OntologyReleaseManifest.ENCODING_VERSION)) + .withMemberCount(members.size()) + .withTotalBytes( + members.stream().mapToLong(OntologyReleaseMember::getPayloadBytes).sum()) + .withContentDigest(OntologyReleaseManifest.digest(members)); + return new Bundle(release, members); + } + + private static OntologyReleaseMember member( + UUID release, MemberKind kind, EntityInterface entity) { + return OntologyReleaseManifest.member( + release, kind, entity.getId(), JsonUtils.getObjectMapper().valueToTree(entity)); + } + + private static String source(OntologyRelease release, MemberKind kind, UUID id) { + return release.getVersionIri() + ":" + kind + ":" + id; + } + + private static void reseal(Bundle bundle) { + bundle + .release() + .withMemberCount(bundle.members().size()) + .withTotalBytes( + bundle.members().stream().mapToLong(OntologyReleaseMember::getPayloadBytes).sum()) + .withContentDigest(OntologyReleaseManifest.digest(bundle.members())); + } + + private record Bundle(OntologyRelease release, List members) {} +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/rdf/RdfExcludedEntitiesTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/rdf/RdfExcludedEntitiesTest.java index 516db3b98446..7f521948ce3f 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/rdf/RdfExcludedEntitiesTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/rdf/RdfExcludedEntitiesTest.java @@ -15,6 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.openmetadata.service.Entity; @@ -22,6 +23,17 @@ @DisplayName("RdfExcludedEntities policy") class RdfExcludedEntitiesTest { + @Test + void nativeWorkingDefinitionsNeverEnterGenericProjection() { + List.of( + Entity.ONTOLOGY, + Entity.ONTOLOGY_CONCEPT, + Entity.ONTOLOGY_PROPERTY, + Entity.ONTOLOGY_AXIOM, + Entity.ONTOLOGY_RELEASE) + .forEach(type -> assertTrue(RdfExcludedEntities.isExcluded(type))); + } + @Test @DisplayName("aiChart is excluded from RDF") void aiChartIsExcluded() { diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/rdf/RdfLiveWriteTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/rdf/RdfLiveWriteTest.java index 45ef739aa847..34561c53eec8 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/rdf/RdfLiveWriteTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/rdf/RdfLiveWriteTest.java @@ -259,7 +259,8 @@ private RdfRepository repository() { (fromId, toId) -> lineageEdgeExists ? Optional.of(new RdfProjectionLoaders.StoredLineageEdge(currentLineageDetails)) - : Optional.empty())); + : Optional.empty(), + () -> new OntologyActiveGraph(Entity.getCollectionDAO()))); } private static EntityRelationship lineage(final UUID fromId, final UUID toId) { diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/feeds/MessageParserTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/feeds/MessageParserTest.java index 9ff13f022c03..7724194f4ce6 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/feeds/MessageParserTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/feeds/MessageParserTest.java @@ -18,9 +18,19 @@ import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.openmetadata.service.resources.feeds.MessageParser.EntityLink; class MessageParserTest { + @ParameterizedTest + @ValueSource(strings = {"ontology", "ontologyConcept", "ontologyProperty", "ontologyRelease"}) + void parseIndependentOntologyLinks(String type) { + EntityLink link = EntityLink.parse("<#E::" + type + "::Sales.Customer>"); + assertEquals(type, link.getEntityType()); + assertEquals("Sales.Customer", link.getEntityFQN()); + } + @Test void parseMessage() { String s = diff --git a/openmetadata-service/src/test/resources/rdf/projection-entities.json b/openmetadata-service/src/test/resources/rdf/projection-entities.json index 72e1d4cdf5cd..b43137a20d30 100644 --- a/openmetadata-service/src/test/resources/rdf/projection-entities.json +++ b/openmetadata-service/src/test/resources/rdf/projection-entities.json @@ -1,4 +1,14 @@ [ + { + "type": "task", + "fields": { + "ontologyReview": { + "entityId": "00000000-0000-0000-0000-000000000001", + "contentDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "author": "author" + } + } + }, { "type": "table", "fields": { diff --git a/openmetadata-spec/src/main/antlr4/org/openmetadata/schema/EntityLink.g4 b/openmetadata-spec/src/main/antlr4/org/openmetadata/schema/EntityLink.g4 index fb4e52ec157a..d4928b578800 100644 --- a/openmetadata-spec/src/main/antlr4/org/openmetadata/schema/EntityLink.g4 +++ b/openmetadata-spec/src/main/antlr4/org/openmetadata/schema/EntityLink.g4 @@ -40,6 +40,10 @@ ENTITY_TYPE | 'glossary' | 'glossaryTerm' | 'relationshipType' + | 'ontology' + | 'ontologyConcept' + | 'ontologyProperty' + | 'ontologyRelease' | 'ontologyAxiom' | 'ontologyChangeSet' | 'databaseService' diff --git a/openmetadata-spec/src/main/java/org/openmetadata/schema/OntologyDefinition.java b/openmetadata-spec/src/main/java/org/openmetadata/schema/OntologyDefinition.java new file mode 100644 index 000000000000..ddce9764f1ee --- /dev/null +++ b/openmetadata-spec/src/main/java/org/openmetadata/schema/OntologyDefinition.java @@ -0,0 +1,15 @@ +package org.openmetadata.schema; + +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.EntityStatus; + +/** Independently stored ontology content and immutable release snapshots. */ +public interface OntologyDefinition extends EntityInterface { + default EntityReference getOntology() { + return null; + } + + default void setOntology(EntityReference ontology) { + throw new UnsupportedOperationException("An ontology has no owning ontology"); + } +} diff --git a/openmetadata-spec/src/main/java/org/openmetadata/schema/OntologyEntityInterface.java b/openmetadata-spec/src/main/java/org/openmetadata/schema/OntologyEntityInterface.java new file mode 100644 index 000000000000..30fa1091309e --- /dev/null +++ b/openmetadata-spec/src/main/java/org/openmetadata/schema/OntologyEntityInterface.java @@ -0,0 +1,10 @@ +package org.openmetadata.schema; + +import java.net.URI; + +/** Ontology definitions with a stable semantic IRI. */ +public interface OntologyEntityInterface extends OntologyDefinition { + URI getIri(); + + void setIri(URI iri); +} diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/activateOntologyRelease.json b/openmetadata-spec/src/main/resources/json/schema/api/data/activateOntologyRelease.json new file mode 100644 index 000000000000..a4ebed041d9f --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/activateOntologyRelease.json @@ -0,0 +1,18 @@ +{ + "$id": "https://open-metadata.org/schema/api/data/activateOntologyRelease.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ActivateOntologyRelease", + "description": "Activate an approved immutable ontology release. Selecting an older release performs an audited rollback.", + "type": "object", + "javaType": "org.openmetadata.schema.api.data.ActivateOntologyRelease", + "properties": { + "releaseId": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "expectedActivationRevision": { + "$ref": "../../type/ontologyState.json#/properties/activationRevision" + } + }, + "required": ["releaseId", "expectedActivationRevision"], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/captureOntologyRelease.json b/openmetadata-spec/src/main/resources/json/schema/api/data/captureOntologyRelease.json new file mode 100644 index 000000000000..ac112799bb05 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/captureOntologyRelease.json @@ -0,0 +1,17 @@ +{ + "$id": "https://open-metadata.org/schema/api/data/captureOntologyRelease.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CaptureOntologyRelease", + "description": "Capture one committed working revision as an immutable ontology release candidate.", + "type": "object", + "javaType": "org.openmetadata.schema.api.data.CaptureOntologyRelease", + "properties": { + "expectedWorkingRevision": { + "$ref": "../../type/ontologyState.json#/properties/workingRevision" + } + }, + "required": [ + "expectedWorkingRevision" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/createOntology.json b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntology.json new file mode 100644 index 000000000000..b04c0121554c --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntology.json @@ -0,0 +1,49 @@ +{ + "$id": "https://open-metadata.org/schema/api/data/createOntology.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CreateOntologyRequest", + "description": "Create or update an independent ontology. Approval is managed through existing approval workflows.", + "type": "object", + "javaType": "org.openmetadata.schema.api.data.CreateOntology", + "javaInterfaces": [ + "org.openmetadata.schema.CreateEntity" + ], + "properties": { + "name": { + "$ref": "../../entity/data/ontology.json#/properties/name" + }, + "displayName": { + "$ref": "../../entity/data/ontology.json#/properties/displayName" + }, + "description": { + "$ref": "../../entity/data/ontology.json#/properties/description" + }, + "owners": { + "$ref": "../../entity/data/ontology.json#/properties/owners" + }, + "reviewers": { + "$ref": "../../entity/data/ontology.json#/properties/reviewers" + }, + "provider": { + "$ref": "../../entity/data/ontology.json#/properties/provider" + }, + "iri": { + "$ref": "../../entity/data/ontology.json#/properties/iri" + }, + "namespace": { + "$ref": "../../entity/data/ontology.json#/properties/namespace" + }, + "layer": { + "$ref": "../../entity/data/ontology.json#/properties/layer" + }, + "prefixes": { + "$ref": "../../entity/data/ontology.json#/properties/prefixes" + } + }, + "required": [ + "name", + "iri", + "namespace" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyAxiom.json b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyAxiom.json index 4e269b6b3e7d..1618de6fc443 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyAxiom.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyAxiom.json @@ -5,7 +5,9 @@ "description": "Create or update a governed OWL axiom.", "type": "object", "javaType": "org.openmetadata.schema.api.data.CreateOntologyAxiom", - "javaInterfaces": ["org.openmetadata.schema.CreateEntity"], + "javaInterfaces": [ + "org.openmetadata.schema.CreateEntity" + ], "properties": { "name": { "$ref": "../../type/basic.json#/definitions/entityName" @@ -16,15 +18,14 @@ "description": { "$ref": "../../type/basic.json#/definitions/markdown" }, - "glossary": { + "ontology": { "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" }, "axiomType": { "$ref": "../../entity/data/ontologyAxiom.json#/definitions/axiomType" }, "subjectIri": { - "type": "string", - "format": "uri" + "$ref": "../../type/ontologyIri.json" }, "expressions": { "type": "array", @@ -33,12 +34,10 @@ } }, "propertyIri": { - "type": "string", - "format": "uri" + "$ref": "../../type/ontologyIri.json" }, "targetIri": { - "type": "string", - "format": "uri" + "$ref": "../../type/ontologyIri.json" }, "literal": { "$ref": "../../type/ontologyExpression.json#/definitions/literal" @@ -46,16 +45,9 @@ "provenance": { "$ref": "../../type/termRelation.json#/definitions/provenance" }, - "entityStatus": { - "description": "Lifecycle stage of the axiom. When omitted, the axiom starts in Draft.", - "$ref": "../../type/status.json" - }, "owners": { "$ref": "../../type/entityReferenceList.json" }, - "reviewers": { - "$ref": "../../type/entityReferenceList.json" - }, "provider": { "$ref": "../../type/basic.json#/definitions/providerType" } @@ -64,7 +56,7 @@ "name", "displayName", "description", - "glossary", + "ontology", "axiomType", "subjectIri", "provenance" diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyChangeSet.json b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyChangeSet.json index 560eba030d2e..5dec67f358d0 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyChangeSet.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyChangeSet.json @@ -5,7 +5,9 @@ "description": "Create or update an ontology authoring session.", "type": "object", "javaType": "org.openmetadata.schema.api.data.CreateOntologyChangeSet", - "javaInterfaces": ["org.openmetadata.schema.CreateEntity"], + "javaInterfaces": [ + "org.openmetadata.schema.CreateEntity" + ], "properties": { "name": { "$ref": "../../type/basic.json#/definitions/entityName" @@ -22,7 +24,6 @@ "items": { "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" }, - "minItems": 1, "uniqueItems": true }, "state": { @@ -44,8 +45,22 @@ }, "provider": { "$ref": "../../type/basic.json#/definitions/providerType" + }, + "ontology": { + "description": "Fully qualified native ontology name. Mutually exclusive with glossaries.", + "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" + }, + "baseWorkingRevision": { + "description": "Expected native working revision when applying this proposal.", + "type": "integer", + "existingJavaType": "java.lang.Long", + "minimum": 0 } }, - "required": ["name", "displayName", "description", "glossaries"], + "required": [ + "name", + "displayName", + "description" + ], "additionalProperties": false } diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyConcept.json b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyConcept.json new file mode 100644 index 000000000000..b8ff34e04416 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyConcept.json @@ -0,0 +1,51 @@ +{ + "$id": "https://open-metadata.org/schema/api/data/createOntologyConcept.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CreateOntologyConceptRequest", + "description": "Create or update an independent ontologyConcept. Approval is managed through existing approval workflows.", + "type": "object", + "javaType": "org.openmetadata.schema.api.data.CreateOntologyConcept", + "javaInterfaces": [ + "org.openmetadata.schema.CreateEntity" + ], + "properties": { + "name": { + "$ref": "../../entity/data/ontologyConcept.json#/properties/name" + }, + "displayName": { + "$ref": "../../entity/data/ontologyConcept.json#/properties/displayName" + }, + "description": { + "$ref": "../../entity/data/ontologyConcept.json#/properties/description" + }, + "owners": { + "$ref": "../../entity/data/ontologyConcept.json#/properties/owners" + }, + "provider": { + "$ref": "../../entity/data/ontologyConcept.json#/properties/provider" + }, + "iri": { + "description": "Semantic IRI preserved for imported definitions. If omitted, creation mints an IRI from the ontology namespace and entity UUID; updates preserve the stored IRI.", + "$ref": "../../entity/data/ontologyConcept.json#/properties/iri" + }, + "conceptType": { + "$ref": "../../entity/data/ontologyConcept.json#/properties/conceptType" + }, + "synonyms": { + "$ref": "../../entity/data/ontologyConcept.json#/properties/synonyms" + }, + "conceptMappings": { + "$ref": "../../entity/data/ontologyConcept.json#/properties/conceptMappings" + }, + "ontology": { + "description": "Fully qualified name of the owning ontology.", + "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" + } + }, + "required": [ + "name", + "ontology", + "conceptType" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyProperty.json b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyProperty.json new file mode 100644 index 000000000000..90d2ea9b6a49 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyProperty.json @@ -0,0 +1,72 @@ +{ + "$id": "https://open-metadata.org/schema/api/data/createOntologyProperty.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CreateOntologyPropertyRequest", + "description": "Create or update an independent ontologyProperty. Approval is managed through existing approval workflows.", + "type": "object", + "javaType": "org.openmetadata.schema.api.data.CreateOntologyProperty", + "javaInterfaces": [ + "org.openmetadata.schema.CreateEntity" + ], + "properties": { + "name": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/name" + }, + "displayName": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/displayName" + }, + "description": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/description" + }, + "owners": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/owners" + }, + "provider": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/provider" + }, + "iri": { + "description": "Semantic IRI preserved for imported definitions. If omitted, creation mints an IRI from the ontology namespace and entity UUID; updates preserve the stored IRI.", + "$ref": "../../entity/data/ontologyProperty.json#/properties/iri" + }, + "kind": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/kind" + }, + "domain": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/domain" + }, + "range": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/range" + }, + "enumValues": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/enumValues" + }, + "unit": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/unit" + }, + "isIdentifier": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/isIdentifier" + }, + "inverse": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/inverse" + }, + "characteristics": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/characteristics" + }, + "propertyChain": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/propertyChain" + }, + "disjointWith": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/disjointWith" + }, + "ontology": { + "description": "Fully qualified name of the owning ontology.", + "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" + } + }, + "required": [ + "name", + "ontology", + "kind" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/ontologyReleaseDiff.json b/openmetadata-spec/src/main/resources/json/schema/api/data/ontologyReleaseDiff.json new file mode 100644 index 000000000000..b87383187945 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/ontologyReleaseDiff.json @@ -0,0 +1,91 @@ +{ + "$id": "https://open-metadata.org/schema/api/data/ontologyReleaseDiff.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OntologyReleaseDiff", + "description": "A paginated comparison of asserted definitions in two immutable releases. An omitted base compares with an empty ontology. Operational changes are excluded; this is not a logical-equivalence result.", + "type": "object", + "javaType": "org.openmetadata.schema.api.data.OntologyReleaseDiff", + "definitions": { + "changeType": { + "type": "string", + "javaType": "org.openmetadata.schema.type.OntologyReleaseChangeType", + "enum": [ + "ADDED", + "CHANGED", + "REMOVED" + ] + }, + "memberChange": { + "type": "object", + "javaType": "org.openmetadata.schema.type.OntologyReleaseMemberChange", + "properties": { + "entity": { + "$ref": "../../type/entityReference.json" + }, + "ontologyId": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "memberKind": { + "type": "string", + "existingJavaType": "org.openmetadata.schema.type.OntologyReleaseMember.MemberKind", + "allOf": [ + { + "$ref": "../../type/ontologyReleaseMember.json#/properties/memberKind" + } + ] + }, + "changeType": { + "$ref": "#/definitions/changeType" + }, + "beforeReleaseId": { + "description": "Release containing the previous member. Absent for additions.", + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "afterReleaseId": { + "description": "Release containing the proposed member. Absent for removals.", + "$ref": "../../type/basic.json#/definitions/uuid" + } + }, + "required": [ + "entity", + "ontologyId", + "memberKind", + "changeType" + ], + "additionalProperties": false + } + }, + "properties": { + "releaseId": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "contentDigest": { + "$ref": "../../entity/data/ontologyRelease.json#/properties/contentDigest" + }, + "baseReleaseId": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "baseContentDigest": { + "$ref": "../../entity/data/ontologyRelease.json#/properties/contentDigest" + }, + "data": { + "description": "Changed members ordered by member kind and entity UUID. References identify the exact snapshots to inspect.", + "type": "array", + "maxItems": 1000, + "items": { + "$ref": "#/definitions/memberChange" + }, + "default": [] + }, + "paging": { + "$ref": "../../type/paging.json" + } + }, + "required": [ + "releaseId", + "contentDigest", + "data", + "paging" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/ontologyReleaseStructureReport.json b/openmetadata-spec/src/main/resources/json/schema/api/data/ontologyReleaseStructureReport.json new file mode 100644 index 000000000000..3a194bd81f9c --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/ontologyReleaseStructureReport.json @@ -0,0 +1,75 @@ +{ + "$id": "https://open-metadata.org/schema/api/data/ontologyReleaseStructureReport.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OntologyReleaseStructureReport", + "description": "Declaration, reference-kind and axiom-shape checks over a sealed release and its pinned imports. This is not a complete OWL 2 DL profile check, a logical consistency result, or publication approval.", + "type": "object", + "javaType": "org.openmetadata.schema.api.data.OntologyReleaseStructureReport", + "definitions": { + "issue": { + "type": "object", + "javaType": "org.openmetadata.schema.type.OntologyReleaseStructureIssue", + "properties": { + "releaseId": { + "description": "Owning release when validating captured content; absent for a working definition.", + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "memberId": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "memberKind": { + "type": "string", + "existingJavaType": "org.openmetadata.schema.type.OntologyReleaseMember.MemberKind", + "allOf": [ + { + "$ref": "../../type/ontologyReleaseMember.json#/properties/memberKind" + } + ] + }, + "violation": { + "$ref": "../../type/ontologyProfileViolation.json" + } + }, + "required": [ + "memberId", + "memberKind", + "violation" + ], + "additionalProperties": false + } + }, + "properties": { + "releaseId": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "contentDigest": { + "$ref": "../../entity/data/ontologyRelease.json#/properties/contentDigest" + }, + "valid": { + "description": "True only when these structural checks find no violations. Other required publication checks are independent.", + "type": "boolean" + }, + "violationCount": { + "description": "Total findings, including those beyond the response limit.", + "type": "integer", + "minimum": 0 + }, + "violations": { + "description": "At most 1000 findings in release/member order. Compare the length with violationCount to detect truncation.", + "type": "array", + "maxItems": 1000, + "items": { + "$ref": "#/definitions/issue" + }, + "default": [] + } + }, + "required": [ + "releaseId", + "contentDigest", + "valid", + "violationCount", + "violations" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/api/data/ontologyValidationError.json b/openmetadata-spec/src/main/resources/json/schema/api/data/ontologyValidationError.json new file mode 100644 index 000000000000..079a869d563c --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/ontologyValidationError.json @@ -0,0 +1,20 @@ +{ + "$id": "https://open-metadata.org/schema/api/data/ontologyValidationError.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OntologyValidationError", + "description": "An invalid working model or release capture, with bounded findings identifying the affected definitions.", + "type": "object", + "javaType": "org.openmetadata.schema.api.data.OntologyValidationError", + "properties": { + "code": { "type": "integer", "minimum": 400, "maximum": 400 }, + "message": { "type": "string" }, + "violationCount": { "type": "integer", "minimum": 1 }, + "violations": { + "type": "array", + "maxItems": 1000, + "items": { "$ref": "ontologyReleaseStructureReport.json#/definitions/issue" } + } + }, + "required": ["code", "message", "violationCount", "violations"], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/data/ontology.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontology.json new file mode 100644 index 000000000000..e651008e19b7 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontology.json @@ -0,0 +1,91 @@ +{ + "$id": "https://open-metadata.org/schema/entity/data/ontology.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Ontology", + "description": "An independently governed ontology namespace. Formal definitions are stored as separate entities.", + "$comment": "@om-entity-type", + "type": "object", + "javaType": "org.openmetadata.schema.entity.data.Ontology", + "javaInterfaces": [ + "org.openmetadata.schema.EntityInterface", + "org.openmetadata.schema.OntologyEntityInterface" + ], + "properties": { + "id": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "name": { + "$ref": "../../type/basic.json#/definitions/entityName" + }, + "displayName": { + "type": "string" + }, + "fullyQualifiedName": { + "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" + }, + "description": { + "$ref": "../../type/basic.json#/definitions/markdown" + }, + "owners": { + "$ref": "../../type/entityReferenceList.json" + }, + "reviewers": { + "$ref": "../../type/entityReferenceList.json" + }, + "version": { + "$ref": "../../type/entityHistory.json#/definitions/entityVersion" + }, + "updatedAt": { + "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "updatedBy": { + "type": "string" + }, + "impersonatedBy": { + "description": "Bot user that performed the action on behalf of the actual user.", + "$ref": "../../type/basic.json#/definitions/impersonatedBy" + }, + "href": { + "$ref": "../../type/basic.json#/definitions/href" + }, + "changeDescription": { + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "incrementalChangeDescription": { + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "deleted": { + "type": "boolean", + "default": false + }, + "provider": { + "$ref": "../../type/basic.json#/definitions/providerType" + }, + "iri": { + "description": "Stable semantic identity of this ontology; independent of its name and catalog record IRI.", + "$ref": "../../type/ontologyIri.json" + }, + "namespace": { + "description": "Absolute namespace used to mint persistent IRIs for native definitions.", + "$ref": "../../type/ontologyIri.json" + }, + "layer": { + "$ref": "../../type/ontologyConfiguration.json#/definitions/layer" + }, + "prefixes": { + "type": "array", + "items": { + "$ref": "../../type/ontologyConfiguration.json#/definitions/prefix" + }, + "default": [] + } + }, + "required": [ + "id", + "name", + "fullyQualifiedName", + "iri", + "namespace" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyAxiom.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyAxiom.json index ad3bbbe8d7ec..9aaf12375b52 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyAxiom.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyAxiom.json @@ -6,7 +6,10 @@ "$comment": "@om-entity-type", "type": "object", "javaType": "org.openmetadata.schema.entity.data.OntologyAxiom", - "javaInterfaces": ["org.openmetadata.schema.EntityInterface"], + "javaInterfaces": [ + "org.openmetadata.schema.EntityInterface", + "org.openmetadata.schema.OntologyDefinition" + ], "definitions": { "axiomType": { "javaType": "org.openmetadata.schema.type.OntologyAxiomType", @@ -37,15 +40,14 @@ "description": { "$ref": "../../type/basic.json#/definitions/markdown" }, - "glossary": { + "ontology": { "$ref": "../../type/entityReference.json" }, "axiomType": { "$ref": "#/definitions/axiomType" }, "subjectIri": { - "type": "string", - "format": "uri" + "$ref": "../../type/ontologyIri.json" }, "expressions": { "description": "Class expressions participating in the axiom, ordered from left to right.", @@ -56,12 +58,10 @@ "default": [] }, "propertyIri": { - "type": "string", - "format": "uri" + "$ref": "../../type/ontologyIri.json" }, "targetIri": { - "type": "string", - "format": "uri" + "$ref": "../../type/ontologyIri.json" }, "literal": { "$ref": "../../type/ontologyExpression.json#/definitions/literal" @@ -69,15 +69,9 @@ "provenance": { "$ref": "../../type/termRelation.json#/definitions/provenance" }, - "entityStatus": { - "$ref": "../../type/status.json" - }, "owners": { "$ref": "../../type/entityReferenceList.json" }, - "reviewers": { - "$ref": "../../type/entityReferenceList.json" - }, "version": { "$ref": "../../type/entityHistory.json#/definitions/entityVersion" }, @@ -114,12 +108,11 @@ "displayName", "fullyQualifiedName", "description", - "glossary", + "ontology", "axiomType", "subjectIri", "expressions", - "provenance", - "entityStatus" + "provenance" ], "additionalProperties": false } diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyChangeSet.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyChangeSet.json index de45d79e46c2..cff8d3d91700 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyChangeSet.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyChangeSet.json @@ -6,12 +6,20 @@ "$comment": "@om-entity-type", "type": "object", "javaType": "org.openmetadata.schema.entity.data.OntologyChangeSet", - "javaInterfaces": ["org.openmetadata.schema.EntityInterface"], + "javaInterfaces": [ + "org.openmetadata.schema.EntityInterface" + ], "definitions": { "state": { "javaType": "org.openmetadata.schema.type.OntologyChangeSetState", "type": "string", - "enum": ["DRAFT", "SUBMITTED", "APPLIED", "APPLY_FAILED", "DISCARDED"], + "enum": [ + "DRAFT", + "SUBMITTED", + "APPLIED", + "APPLY_FAILED", + "DISCARDED" + ], "default": "DRAFT" } }, @@ -85,6 +93,16 @@ }, "provider": { "$ref": "../../type/basic.json#/definitions/providerType" + }, + "ontology": { + "description": "Native ontology scope. Mutually exclusive with glossaries.", + "$ref": "../../type/entityReference.json" + }, + "baseWorkingRevision": { + "description": "Native working revision against which all operations were proposed. Required for native scope.", + "type": "integer", + "existingJavaType": "java.lang.Long", + "minimum": 0 } }, "required": [ @@ -93,7 +111,6 @@ "displayName", "fullyQualifiedName", "description", - "glossaries", "state", "operations", "undoCursor" diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyConcept.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyConcept.json new file mode 100644 index 000000000000..d4569996e81b --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyConcept.json @@ -0,0 +1,115 @@ +{ + "$id": "https://open-metadata.org/schema/entity/data/ontologyConcept.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OntologyConcept", + "description": "A formal class, SKOS concept, or both, owned by an ontology independently of glossary terms.", + "$comment": "@om-entity-type", + "type": "object", + "javaType": "org.openmetadata.schema.entity.data.OntologyConcept", + "javaInterfaces": [ + "org.openmetadata.schema.EntityInterface", + "org.openmetadata.schema.OntologyEntityInterface" + ], + "properties": { + "id": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "name": { + "$ref": "../../type/basic.json#/definitions/entityName" + }, + "displayName": { + "type": "string" + }, + "fullyQualifiedName": { + "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" + }, + "description": { + "$ref": "../../type/basic.json#/definitions/markdown" + }, + "owners": { + "$ref": "../../type/entityReferenceList.json" + }, + "version": { + "$ref": "../../type/entityHistory.json#/definitions/entityVersion" + }, + "updatedAt": { + "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "updatedBy": { + "type": "string" + }, + "impersonatedBy": { + "description": "Bot user that performed the action on behalf of the actual user.", + "$ref": "../../type/basic.json#/definitions/impersonatedBy" + }, + "href": { + "$ref": "../../type/basic.json#/definitions/href" + }, + "changeDescription": { + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "incrementalChangeDescription": { + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "deleted": { + "type": "boolean", + "default": false + }, + "provider": { + "$ref": "../../type/basic.json#/definitions/providerType" + }, + "ontology": { + "description": "Owning ontology entity. Glossaries do not own ontology definitions.", + "$ref": "../../type/entityReference.json" + }, + "iri": { + "description": "Persisted semantic IRI. Renaming the catalog entity must preserve this identity.", + "$ref": "../../type/ontologyIri.json" + }, + "conceptType": { + "$ref": "../../type/ontologyConceptType.json" + }, + "synonyms": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true, + "default": [] + }, + "conceptMappings": { + "type": "array", + "items": { + "$ref": "../../type/conceptMapping.json" + }, + "default": [] + }, + "superClasses": { + "description": "Direct named superclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not stored or independently editable.", + "type": "array", + "items": { + "$ref": "../../type/ontologyIri.json" + }, + "existingJavaType": "java.util.List", + "readOnly": true + }, + "subClasses": { + "description": "Direct named subclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not stored or independently editable.", + "type": "array", + "items": { + "$ref": "../../type/ontologyIri.json" + }, + "existingJavaType": "java.util.List", + "readOnly": true + } + }, + "required": [ + "id", + "name", + "fullyQualifiedName", + "iri", + "ontology", + "conceptType" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyProperty.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyProperty.json new file mode 100644 index 000000000000..1b9a2132fb38 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyProperty.json @@ -0,0 +1,166 @@ +{ + "$id": "https://open-metadata.org/schema/entity/data/ontologyProperty.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OntologyProperty", + "description": "A formal object, data, or annotation property owned by an ontology, independent of glossary relation types.", + "$comment": "@om-entity-type", + "type": "object", + "javaType": "org.openmetadata.schema.entity.data.OntologyProperty", + "javaInterfaces": [ + "org.openmetadata.schema.EntityInterface", + "org.openmetadata.schema.OntologyEntityInterface" + ], + "definitions": { + "kind": { + "javaType": "org.openmetadata.schema.type.OntologyPropertyKind", + "type": "string", + "enum": [ + "OBJECT", + "DATA", + "ANNOTATION" + ] + } + }, + "properties": { + "id": { + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "name": { + "$ref": "../../type/basic.json#/definitions/entityName" + }, + "displayName": { + "type": "string" + }, + "fullyQualifiedName": { + "$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName" + }, + "description": { + "$ref": "../../type/basic.json#/definitions/markdown" + }, + "owners": { + "$ref": "../../type/entityReferenceList.json" + }, + "version": { + "$ref": "../../type/entityHistory.json#/definitions/entityVersion" + }, + "updatedAt": { + "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "updatedBy": { + "type": "string" + }, + "impersonatedBy": { + "description": "Bot user that performed the action on behalf of the actual user.", + "$ref": "../../type/basic.json#/definitions/impersonatedBy" + }, + "href": { + "$ref": "../../type/basic.json#/definitions/href" + }, + "changeDescription": { + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "incrementalChangeDescription": { + "$ref": "../../type/entityHistory.json#/definitions/changeDescription" + }, + "deleted": { + "type": "boolean", + "default": false + }, + "provider": { + "$ref": "../../type/basic.json#/definitions/providerType" + }, + "ontology": { + "description": "Owning ontology entity. Glossaries do not own ontology definitions.", + "$ref": "../../type/entityReference.json" + }, + "iri": { + "description": "Persisted semantic IRI. Renaming the catalog entity must preserve this identity.", + "$ref": "../../type/ontologyIri.json" + }, + "kind": { + "$ref": "#/definitions/kind" + }, + "domain": { + "description": "Semantic IRIs of the classes in the property domain.", + "type": "array", + "items": { + "$ref": "../../type/ontologyIri.json" + }, + "uniqueItems": true, + "$comment": "A Java URI set would merge identifiers differing only in scheme/host casing or percent-escape spelling.", + "existingJavaType": "java.util.List", + "default": [] + }, + "range": { + "description": "Semantic IRIs of range classes or datatypes.", + "type": "array", + "items": { + "$ref": "../../type/ontologyIri.json" + }, + "uniqueItems": true, + "existingJavaType": "java.util.List", + "default": [] + }, + "enumValues": { + "description": "Allowed typed values for a DATA property, projected as an OWL data enumeration. An absent datatype denotes xsd:string. Empty means no enumeration constraint.", + "type": "array", + "items": { + "$ref": "../../type/ontologyExpression.json#/definitions/literal" + }, + "uniqueItems": true, + "maxItems": 1000, + "existingJavaType": "java.util.List", + "default": [] + }, + "unit": { + "description": "Unit IRI or display symbol for a DATA property, retained as an annotation without implying conversion.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "isIdentifier": { + "description": "Identifier intent for a DATA property. This annotation does not assert functionality, an OWL key, or uniqueness in source data.", + "type": "boolean", + "default": false + }, + "inverse": { + "description": "Semantic IRI of the inverse object property.", + "$ref": "../../type/ontologyIri.json" + }, + "characteristics": { + "type": "array", + "items": { + "$ref": "relationshipType.json#/definitions/characteristic" + }, + "uniqueItems": true, + "default": [] + }, + "propertyChain": { + "description": "Ordered object-property IRIs whose composition implies this property.", + "type": "array", + "items": { + "$ref": "../../type/ontologyIri.json" + }, + "default": [] + }, + "disjointWith": { + "description": "Semantic IRIs of properties disjoint with this property.", + "type": "array", + "items": { + "$ref": "../../type/ontologyIri.json" + }, + "uniqueItems": true, + "existingJavaType": "java.util.List", + "default": [] + } + }, + "required": [ + "id", + "name", + "fullyQualifiedName", + "iri", + "ontology", + "kind" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyRelease.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyRelease.json new file mode 100644 index 000000000000..8f4adb9afdf3 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyRelease.json @@ -0,0 +1,153 @@ +{ + "$id": "https://open-metadata.org/schema/entity/data/ontologyRelease.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OntologyRelease", + "description": "Sealed ontology content, stored as immutable SQL members and reviewed through the existing approval workflow.", + "$comment": "@om-entity-type", + "type": "object", + "javaType": "org.openmetadata.schema.entity.data.OntologyRelease", + "javaInterfaces": [ + "org.openmetadata.schema.EntityInterface", + "org.openmetadata.schema.OntologyDefinition" + ], + "properties": { + "id": { + "$ref": "ontology.json#/properties/id" + }, + "name": { + "$ref": "ontology.json#/properties/name" + }, + "displayName": { + "$ref": "ontology.json#/properties/displayName" + }, + "fullyQualifiedName": { + "$ref": "ontology.json#/properties/fullyQualifiedName" + }, + "description": { + "$ref": "ontology.json#/properties/description" + }, + "ontology": { + "$ref": "ontologyConcept.json#/properties/ontology" + }, + "owners": { + "$ref": "ontology.json#/properties/owners" + }, + "reviewers": { + "$ref": "ontology.json#/properties/reviewers" + }, + "version": { + "$ref": "ontology.json#/properties/version" + }, + "updatedAt": { + "$ref": "ontology.json#/properties/updatedAt" + }, + "updatedBy": { + "$ref": "ontology.json#/properties/updatedBy" + }, + "impersonatedBy": { + "$ref": "ontology.json#/properties/impersonatedBy" + }, + "href": { + "$ref": "ontology.json#/properties/href" + }, + "changeDescription": { + "$ref": "ontology.json#/properties/changeDescription" + }, + "incrementalChangeDescription": { + "$ref": "ontology.json#/properties/incrementalChangeDescription" + }, + "deleted": { + "$ref": "ontology.json#/properties/deleted" + }, + "provider": { + "$ref": "ontology.json#/properties/provider" + }, + "entityStatus": { + "$ref": "../../type/status.json" + }, + "reviewAuthor": { + "description": "Author of the current governed content revision; assigned by the server.", + "type": "string" + }, + "reviewTask": { + "description": "Current approval task, assigned by the existing workflow infrastructure. Clients cannot set this field.", + "$ref": "../../type/basic.json#/definitions/uuid" + }, + "releaseRevision": { + "description": "Monotonic release number allocated under the owning ontology state lock.", + "type": "integer", + "minimum": 1, + "existingJavaType": "java.lang.Long" + }, + "sourceWorkingRevision": { + "$ref": "../../type/ontologyState.json#/properties/workingRevision" + }, + "ontologyIri": { + "$ref": "../../type/ontologyIri.json" + }, + "versionIri": { + "$ref": "../../type/ontologyIri.json" + }, + "encodingVersion": { + "description": "Canonical manifest and member encoding used to reproduce the digest.", + "type": "string", + "enum": [ + "ontology-release-v1" + ] + }, + "contentDigest": { + "description": "SHA-256 of the sealed manifest, including ordered member identities and payload digests.", + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "memberCount": { + "type": "integer", + "minimum": 1 + }, + "totalBytes": { + "description": "Total UTF-8 size of canonical member payloads.", + "type": "integer", + "minimum": 1, + "existingJavaType": "java.lang.Long" + }, + "sealedAt": { + "$ref": "../../type/basic.json#/definitions/timestamp" + }, + "sealedBy": { + "type": "string", + "minLength": 1 + }, + "logicalCheck": { + "description": "Logical consistency has not been checked. Structural and OWL 2 DL profile checks do not establish consistency.", + "type": "string", + "enum": [ + "NOT_RUN" + ], + "default": "NOT_RUN", + "readOnly": true + }, + "baseReleaseId": { + "description": "Active release at capture, retained as the immutable review comparison baseline.", + "$ref": "../../type/basic.json#/definitions/uuid" + } + }, + "required": [ + "id", + "name", + "fullyQualifiedName", + "ontology", + "entityStatus", + "releaseRevision", + "sourceWorkingRevision", + "ontologyIri", + "versionIri", + "encodingVersion", + "contentDigest", + "memberCount", + "totalBytes", + "sealedAt", + "sealedBy", + "logicalCheck" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/tasks/task.json b/openmetadata-spec/src/main/resources/json/schema/entity/tasks/task.json index 10d266da6d0d..11646cbe2b45 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/tasks/task.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/tasks/task.json @@ -461,6 +461,10 @@ "description": "When true indicates the entity has been soft deleted.", "type": "boolean", "default": false + }, + "ontologyReview": { + "description": "Immutable, server-managed review target for ontology approval tasks.", + "$ref": "../../type/ontologyReviewTarget.json" } }, "required": ["id", "name", "category", "type", "status", "createdBy"], diff --git a/openmetadata-spec/src/main/resources/json/schema/type/conceptMapping.json b/openmetadata-spec/src/main/resources/json/schema/type/conceptMapping.json index 69772f487512..490a2eb8751a 100644 --- a/openmetadata-spec/src/main/resources/json/schema/type/conceptMapping.json +++ b/openmetadata-spec/src/main/resources/json/schema/type/conceptMapping.json @@ -22,8 +22,7 @@ "properties": { "conceptIri": { "description": "External concept IRI to map this ontology term to.", - "type": "string", - "format": "uri" + "$ref": "ontologyIri.json" }, "mappingType": { "description": "Type of mapping used for the external concept alignment.", @@ -31,8 +30,7 @@ }, "schemeIri": { "description": "Optional external concept scheme IRI for the mapped concept.", - "type": "string", - "format": "uri" + "$ref": "ontologyIri.json" }, "source": { "description": "Optional source label or catalog for the external concept.", diff --git a/openmetadata-spec/src/main/resources/json/schema/type/ontologyChangeOperation.json b/openmetadata-spec/src/main/resources/json/schema/type/ontologyChangeOperation.json index b88df54dfe2e..167b8a719c21 100644 --- a/openmetadata-spec/src/main/resources/json/schema/type/ontologyChangeOperation.json +++ b/openmetadata-spec/src/main/resources/json/schema/type/ontologyChangeOperation.json @@ -22,13 +22,20 @@ "UPSERT_MAPPING", "DELETE_MAPPING", "UPSERT_AXIOM", - "DELETE_AXIOM" + "DELETE_AXIOM", + "UPSERT_CONCEPT", + "DELETE_CONCEPT", + "UPSERT_PROPERTY", + "DELETE_PROPERTY" ] }, "state": { "javaType": "org.openmetadata.schema.type.OntologyChangeOperationState", "type": "string", - "enum": ["ACTIVE", "UNDONE"], + "enum": [ + "ACTIVE", + "UNDONE" + ], "default": "ACTIVE" } }, @@ -69,7 +76,9 @@ "sourceMemoryIds": { "description": "Published context memories that support this AI suggestion.", "type": "array", - "items": { "$ref": "basic.json#/definitions/uuid" }, + "items": { + "$ref": "basic.json#/definitions/uuid" + }, "uniqueItems": true, "maxItems": 20 }, @@ -90,8 +99,18 @@ }, "state": { "$ref": "#/definitions/state" + }, + "concept": { + "$ref": "../entity/data/ontologyConcept.json" + }, + "property": { + "$ref": "../entity/data/ontologyProperty.json" } }, - "required": ["id", "operationType", "state"], + "required": [ + "id", + "operationType", + "state" + ], "additionalProperties": false } diff --git a/openmetadata-spec/src/main/resources/json/schema/type/ontologyConfiguration.json b/openmetadata-spec/src/main/resources/json/schema/type/ontologyConfiguration.json index 6a7e9fc27451..38925e8dc310 100644 --- a/openmetadata-spec/src/main/resources/json/schema/type/ontologyConfiguration.json +++ b/openmetadata-spec/src/main/resources/json/schema/type/ontologyConfiguration.json @@ -23,8 +23,7 @@ "pattern": "^[A-Za-z][A-Za-z0-9._-]*$" }, "namespace": { - "type": "string", - "format": "uri" + "$ref": "ontologyIri.json" } }, "required": ["prefix", "namespace"], @@ -34,8 +33,7 @@ "properties": { "baseIri": { "description": "Absolute base IRI used when minting governed concepts.", - "type": "string", - "format": "uri" + "$ref": "ontologyIri.json" }, "layer": { "$ref": "#/definitions/layer" diff --git a/openmetadata-spec/src/main/resources/json/schema/type/ontologyExpression.json b/openmetadata-spec/src/main/resources/json/schema/type/ontologyExpression.json index 36a1c9fdc74f..8d697a14bdde 100644 --- a/openmetadata-spec/src/main/resources/json/schema/type/ontologyExpression.json +++ b/openmetadata-spec/src/main/resources/json/schema/type/ontologyExpression.json @@ -10,13 +10,26 @@ "description": "Kind of recursive class expression.", "javaType": "org.openmetadata.schema.type.OntologyExpressionKind", "type": "string", - "enum": ["NAMED_CLASS", "INTERSECTION", "UNION", "ONE_OF", "RESTRICTION"] + "enum": [ + "NAMED_CLASS", + "INTERSECTION", + "UNION", + "ONE_OF", + "RESTRICTION" + ] }, "restrictionKind": { "description": "Supported OWL restriction operator.", "javaType": "org.openmetadata.schema.type.OntologyRestrictionKind", "type": "string", - "enum": ["SOME", "ONLY", "VALUE", "MIN", "MAX", "EXACT"] + "enum": [ + "SOME", + "ONLY", + "VALUE", + "MIN", + "MAX", + "EXACT" + ] }, "literal": { "description": "A typed OWL literal.", @@ -27,11 +40,12 @@ "type": "string" }, "datatypeIri": { - "type": "string", - "format": "uri" + "$ref": "ontologyIri.json" } }, - "required": ["value"], + "required": [ + "value" + ], "additionalProperties": false } }, @@ -41,8 +55,7 @@ }, "classIri": { "description": "Class IRI when kind is NAMED_CLASS.", - "type": "string", - "format": "uri" + "$ref": "ontologyIri.json" }, "operands": { "description": "Nested expressions for intersection and union expressions. Minimum operand counts are enforced per expression kind by the OWL profile guard.", @@ -55,15 +68,14 @@ "description": "Named individuals used by an enumeration expression. Presence is enforced per expression kind by the OWL profile guard.", "type": "array", "items": { - "type": "string", - "format": "uri" + "$ref": "ontologyIri.json" }, - "uniqueItems": true + "uniqueItems": true, + "existingJavaType": "java.util.List" }, "propertyIri": { "description": "Property constrained by a restriction.", - "type": "string", - "format": "uri" + "$ref": "ontologyIri.json" }, "restrictionKind": { "$ref": "#/definitions/restrictionKind" @@ -79,14 +91,15 @@ }, "individualIri": { "description": "Individual value for an object has-value restriction.", - "type": "string", - "format": "uri" + "$ref": "ontologyIri.json" }, "literal": { "description": "Literal value for a data has-value restriction.", "$ref": "#/definitions/literal" } }, - "required": ["kind"], + "required": [ + "kind" + ], "additionalProperties": false } diff --git a/openmetadata-spec/src/main/resources/json/schema/type/ontologyIri.json b/openmetadata-spec/src/main/resources/json/schema/type/ontologyIri.json new file mode 100644 index 000000000000..a0a1d6deea6a --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/type/ontologyIri.json @@ -0,0 +1,9 @@ +{ + "$id": "https://open-metadata.org/schema/type/ontologyIri.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OntologyIri", + "description": "Absolute semantic IRI, preserved exactly without URL normalization, case folding, or percent encoding.", + "type": "string", + "format": "iri", + "existingJavaType": "java.net.URI" +} diff --git a/openmetadata-spec/src/main/resources/json/schema/type/ontologyProfileViolation.json b/openmetadata-spec/src/main/resources/json/schema/type/ontologyProfileViolation.json index 475efe44dcb8..b4d106c2ef19 100644 --- a/openmetadata-spec/src/main/resources/json/schema/type/ontologyProfileViolation.json +++ b/openmetadata-spec/src/main/resources/json/schema/type/ontologyProfileViolation.json @@ -9,7 +9,10 @@ "severity": { "javaType": "org.openmetadata.schema.type.OntologyProfileViolationSeverity", "type": "string", - "enum": ["ERROR", "WARNING"] + "enum": [ + "ERROR", + "WARNING" + ] }, "code": { "javaType": "org.openmetadata.schema.type.OntologyProfileViolationCode", @@ -21,7 +24,12 @@ "TRANSITIVE_CARDINALITY", "NON_SIMPLE_CARDINALITY", "ILLEGAL_PUNNING", - "PROPERTY_CHAIN_REGULARITY" + "PROPERTY_CHAIN_REGULARITY", + "UNDECLARED_PROPERTY", + "NON_SIMPLE_PROPERTY", + "UNDECLARED_CLASS", + "INVALID_CLASS_KIND", + "SUBCLASS_CYCLE" ] } }, @@ -39,6 +47,11 @@ "type": "string" } }, - "required": ["severity", "code", "path", "message"], + "required": [ + "severity", + "code", + "path", + "message" + ], "additionalProperties": false } diff --git a/openmetadata-spec/src/main/resources/json/schema/type/ontologyReference.json b/openmetadata-spec/src/main/resources/json/schema/type/ontologyReference.json new file mode 100644 index 000000000000..2f7d79f04b4e --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/type/ontologyReference.json @@ -0,0 +1,45 @@ +{ + "$id": "https://open-metadata.org/schema/type/ontologyReference.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OntologyReference", + "description": "A derived reference from a working ontology definition to an exact semantic IRI. References are scoped to their owning ontology, not resolved globally.", + "type": "object", + "javaType": "org.openmetadata.schema.type.OntologyReference", + "properties": { + "ontologyId": { + "$ref": "basic.json#/definitions/uuid" + }, + "source": { + "$ref": "entityReference.json" + }, + "role": { + "javaType": "org.openmetadata.schema.type.OntologyReferenceRole", + "type": "string", + "enum": [ + "DOMAIN", + "RANGE", + "INVERSE", + "PROPERTY_CHAIN", + "DISJOINT_PROPERTY", + "AXIOM_SUBJECT", + "PROPERTY", + "CLASS_EXPRESSION", + "INDIVIDUAL", + "DATATYPE", + "MAPPING_TARGET", + "MAPPING_SCHEME", + "SUPER_CLASS" + ] + }, + "iri": { + "$ref": "ontologyIri.json" + } + }, + "required": [ + "ontologyId", + "source", + "role", + "iri" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/type/ontologyReleaseEvent.json b/openmetadata-spec/src/main/resources/json/schema/type/ontologyReleaseEvent.json new file mode 100644 index 000000000000..e5ce427e976b --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/type/ontologyReleaseEvent.json @@ -0,0 +1,42 @@ +{ + "$id": "https://open-metadata.org/schema/type/ontologyReleaseEvent.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OntologyReleaseEvent", + "description": "Append-only evidence committed with an ontology release capture, completed approval workflow decision, or activation.", + "type": "object", + "javaType": "org.openmetadata.schema.type.OntologyReleaseEvent", + "properties": { + "id": {"$ref": "basic.json#/definitions/uuid"}, + "ontologyId": {"$ref": "basic.json#/definitions/uuid"}, + "releaseId": {"$ref": "basic.json#/definitions/uuid"}, + "eventType": { + "type": "string", + "enum": ["CAPTURED", "APPROVED", "REJECTED", "ACTIVATED"] + }, + "createdAt": {"$ref": "basic.json#/definitions/timestamp"}, + "createdBy": {"type": "string", "minLength": 1}, + "contentDigest": {"$ref": "../entity/data/ontologyRelease.json#/properties/contentDigest"}, + "activationRevision": { + "description": "The new activation revision committed with an ACTIVATED event, including rollback.", + "$ref": "ontologyState.json#/properties/activationRevision" + }, + "previousReleaseId": { + "description": "Previously active release, absent for the first activation.", + "$ref": "basic.json#/definitions/uuid" + }, + "taskId": {"$ref": "basic.json#/definitions/uuid"}, + "workflowDefinitionId": {"$ref": "basic.json#/definitions/uuid"}, + "workflowInstanceId": {"$ref": "basic.json#/definitions/uuid"}, + "reviewTarget": {"$ref": "ontologyReviewTarget.json"}, + "voters": { + "description": "Distinct reviewers whose votes were accepted by the completed workflow.", + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true, + "existingJavaType": "java.util.List" + }, + "resolution": {"$ref": "../entity/tasks/task.json#/definitions/taskResolution"} + }, + "required": ["id", "ontologyId", "releaseId", "eventType", "createdAt", "createdBy", "contentDigest"], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/type/ontologyReleaseMember.json b/openmetadata-spec/src/main/resources/json/schema/type/ontologyReleaseMember.json new file mode 100644 index 000000000000..af18c9b2f731 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/type/ontologyReleaseMember.json @@ -0,0 +1,54 @@ +{ + "$id": "https://open-metadata.org/schema/type/ontologyReleaseMember.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OntologyReleaseMember", + "description": "An immutable, bounded snapshot in a sealed ontology release.", + "type": "object", + "javaType": "org.openmetadata.schema.type.OntologyReleaseMember", + "properties": { + "releaseId": { + "$ref": "basic.json#/definitions/uuid" + }, + "memberKind": { + "type": "string", + "enum": [ + "ONTOLOGY", + "CONCEPT", + "PROPERTY", + "AXIOM", + "ASSOCIATION", + "SHAPE" + ] + }, + "memberId": { + "$ref": "basic.json#/definitions/uuid" + }, + "sourceVersion": { + "$ref": "entityHistory.json#/definitions/entityVersion" + }, + "payload": { + "description": "Captured content; readers never resolve this through a mutable definition or entity history.", + "type": "object", + "existingJavaType": "com.fasterxml.jackson.databind.JsonNode", + "additionalProperties": true + }, + "payloadDigest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "payloadBytes": { + "type": "integer", + "minimum": 1, + "existingJavaType": "java.lang.Long" + } + }, + "required": [ + "releaseId", + "memberKind", + "memberId", + "payload", + "payloadDigest", + "payloadBytes" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/type/ontologyReviewTarget.json b/openmetadata-spec/src/main/resources/json/schema/type/ontologyReviewTarget.json new file mode 100644 index 000000000000..e1156b62b898 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/type/ontologyReviewTarget.json @@ -0,0 +1,37 @@ +{ + "$id": "https://open-metadata.org/schema/type/ontologyReviewTarget.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OntologyReviewTarget", + "description": "Server-captured immutable ontology release reviewed by an existing approval workflow task.", + "type": "object", + "javaType": "org.openmetadata.schema.type.OntologyReviewTarget", + "properties": { + "entityId": { + "$ref": "basic.json#/definitions/uuid" + }, + "ontologyId": { + "description": "Owning ontology ID used to serialize release review decisions.", + "$ref": "basic.json#/definitions/uuid" + }, + "entityVersion": { + "description": "Entity-history version submitted for review; reviewers read this frozen release header.", + "$ref": "entityHistory.json#/definitions/entityVersion" + }, + "contentDigest": { + "description": "SHA-256 of the ontology-review-v3 sealed release identity.", + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "author": { + "type": "string" + } + }, + "required": [ + "entityId", + "ontologyId", + "entityVersion", + "contentDigest", + "author" + ], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/json/schema/type/ontologyState.json b/openmetadata-spec/src/main/resources/json/schema/type/ontologyState.json new file mode 100644 index 000000000000..ee6d9fd905fb --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/type/ontologyState.json @@ -0,0 +1,35 @@ +{ + "$id": "https://open-metadata.org/schema/type/ontologyState.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "OntologyState", + "description": "SQL concurrency revisions and the active immutable release of an ontology.", + "type": "object", + "javaType": "org.openmetadata.schema.type.OntologyState", + "properties": { + "ontologyId": { + "$ref": "basic.json#/definitions/uuid" + }, + "workingRevision": { + "description": "Monotonic revision of committed working-model mutations.", + "type": "integer", + "minimum": 0, + "existingJavaType": "java.lang.Long" + }, + "lastReleaseRevision": { + "type": "integer", + "minimum": 0, + "existingJavaType": "java.lang.Long" + }, + "activeReleaseId": { + "$ref": "basic.json#/definitions/uuid" + }, + "activationRevision": { + "description": "Compare-and-set revision, advanced by both activation and rollback.", + "type": "integer", + "minimum": 0, + "existingJavaType": "java.lang.Long" + } + }, + "required": ["ontologyId", "workingRevision", "lastReleaseRevision", "activationRevision"], + "additionalProperties": false +} diff --git a/openmetadata-spec/src/main/resources/rdf/ontology/openmetadata.ttl b/openmetadata-spec/src/main/resources/rdf/ontology/openmetadata.ttl index 232a8d7496a8..c1eee42c85a5 100644 --- a/openmetadata-spec/src/main/resources/rdf/ontology/openmetadata.ttl +++ b/openmetadata-spec/src/main/resources/rdf/ontology/openmetadata.ttl @@ -2771,10 +2771,6 @@ om:glossaries a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "glossaries" . -om:glossary a rdf:Property ; - om:projectionStatus om:Stored ; - rdfs:label "glossary" . - om:governanceMetadata a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "governanceMetadata" . @@ -3205,6 +3201,10 @@ om:joins a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "joins" . +om:kind a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "kind" . + om:knowledgeBases a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "knowledgeBases" . @@ -3241,6 +3241,10 @@ om:latestResult a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "latestResult" . +om:layer a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "layer" . + om:lifecycleAccessed a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "lifecycleAccessed" . @@ -3358,6 +3362,10 @@ om:nameSpace a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "nameSpace" . +om:namespace a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "namespace" . + om:namespaces a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "namespaces" . @@ -3382,6 +3390,10 @@ om:odcsQualityRules a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "odcsQualityRules" . +om:ontology a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "ontology" . + om:ontologyConfiguration a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "ontologyConfiguration" . @@ -3494,6 +3506,10 @@ om:prefix a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "prefix" . +om:prefixes a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "prefixes" . + om:primaryEntity a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "primaryEntity" . @@ -3684,6 +3700,14 @@ om:reviewTask a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "reviewTask" . +om:reviewAuthor a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "reviewAuthor" . + +om:ontologyReview a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "ontologyReview" . + om:reviewers a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "reviewers" . @@ -3876,6 +3900,10 @@ om:supportsInterrupt a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "supportsInterrupt" . +om:synonyms a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "synonyms" . + om:system a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "system" . @@ -4080,3 +4108,83 @@ om:workflowType a rdf:Property ; om:worksheetId a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "worksheetId" . + +om:contentDigest a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "contentDigest" . + +om:logicalCheck a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "logicalCheck" . + +om:superClasses a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "superClasses" . + +om:subClasses a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "subClasses" . + +om:encodingVersion a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "encodingVersion" . + +om:memberCount a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "memberCount" . + +om:ontologyIri a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "ontologyIri" . + +om:releaseRevision a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "releaseRevision" . + +om:baseReleaseId a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "baseReleaseId" . + +om:baseWorkingRevision a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "baseWorkingRevision" . + +om:sealedAt a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "sealedAt" . + +om:sealedBy a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "sealedBy" . + +om:sourceWorkingRevision a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "sourceWorkingRevision" . + +om:totalBytes a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "totalBytes" . + +om:versionIri a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "versionIri" . + +om:enumValues a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "enumValues" . + +om:unit a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "unit" . + +om:isIdentifier a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "isIdentifier" . + +om:unitAnnotation a owl:AnnotationProperty ; + rdfs:label "unit annotation" ; + rdfs:comment "Authored unit IRI or display symbol, without conversion semantics." . + +om:identifierIntent a owl:AnnotationProperty ; + rdfs:label "identifier intent" ; + rdfs:comment "Authored identifier intent, without asserting functionality, an OWL key or source-data uniqueness." . diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyReview.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyReview.spec.ts new file mode 100644 index 000000000000..4e68da184c24 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyReview.spec.ts @@ -0,0 +1,1071 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { APIRequestContext, Page } from '@playwright/test'; +import { readFile } from 'fs/promises'; +import { ContextMemory } from '../../../src/generated/entity/context/contextMemory'; +import { Ontology } from '../../../src/generated/entity/data/ontology'; +import { OntologyAxiom } from '../../../src/generated/entity/data/ontologyAxiom'; +import { OntologyChangeSet } from '../../../src/generated/entity/data/ontologyChangeSet'; +import { OntologyConcept } from '../../../src/generated/entity/data/ontologyConcept'; +import { OntologyProperty } from '../../../src/generated/entity/data/ontologyProperty'; +import { OntologyRelease } from '../../../src/generated/entity/data/ontologyRelease'; +import { Task } from '../../../src/generated/entity/tasks/task'; +import { OntologyState } from '../../../src/generated/type/ontologyState'; +import { expect, test } from '../../support/fixtures/isolatedUser'; +import { deleteFixtureEntity, okJson } from '../../utils/apiResponse'; +import { fillCodeEditor } from '../../utils/codeEditor'; +import { fullUuid, getWorkerAdminAPIContext, uuid } from '../../utils/common'; +import { readGraphEdges, readNodePositions } from '../../utils/ontologyStudio'; + +test.use({ isolatedUserOptions: { isAdmin: true } }); + +const submitReview = async ( + api: APIRequestContext, + type: string, + fqn: string, + tasks: string[] +) => { + const task = await okJson( + await api.post('/api/v1/tasks', { + data: { + name: `Review ${fqn}`, + category: 'Approval', + type: 'RequestApproval', + about: `<#E::${type}::${fqn}>`, + }, + }), + 'Submit ontology review' + ); + tasks.push(task.id); + await expect + .poll(async () => { + const ready = await okJson( + await api.get(`/api/v1/tasks/${task.id}`), + 'Read ontology review' + ); + + return ready.availableTransitions?.length; + }) + .toBe(2); + + return task; +}; + +const approveReview = async (page: Page, task: Task) => { + const resolution = page.waitForResponse( + (response) => + response.url().endsWith(`/api/v1/tasks/${task.id}/resolve`) && + response.request().method() === 'POST' + ); + await page.getByTestId('task-approve').click(); + expect((await resolution).status()).toBe(200); + await expect(page.getByTestId('task-status-badge')).toHaveText('Approved'); +}; + +const selectOntology = async (page: Page, ontology: Ontology) => { + const listing = page.waitForResponse( + (response) => new URL(response.url()).pathname === '/api/v1/ontologies' + ); + await page + .getByRole('button', { name: 'Select Ontology', exact: true }) + .click(); + let result = await okJson<{ data: Ontology[]; paging: { after?: string } }>( + await listing, + 'List native ontologies' + ); + const node = page.getByTestId(`tree-node-${ontology.id}`); + while (!result.data.some((item) => item.id === ontology.id)) { + expect( + result.paging.after, + 'Created ontology is available to its author' + ).toBeTruthy(); + const nextPage = page.waitForResponse( + (response) => new URL(response.url()).pathname === '/api/v1/ontologies' + ); + await page + .getByTestId('tree-node-load-more-root') + .getByRole('button') + .click(); + result = await okJson( + await nextPage, + 'Read the next ontology page' + ); + } + await node.click(); +}; + +const openPublication = async (page: Page, ontology: Ontology) => { + await page.goto('/governance/ontology'); + await selectOntology(page, ontology); + await page.getByTestId('mode-tab-releases').click(); + await expect( + page.getByRole('button', { name: 'Capture release', exact: true }) + ).toBeEnabled(); +}; + +const activateRelease = async ( + page: Page, + ontology: Ontology, + release: OntologyRelease, + expectedRevision: number +) => { + const activation = page.waitForResponse( + (response) => + response.url().endsWith(`/api/v1/ontologies/${ontology.id}/activation`) && + response.request().method() === 'PUT' + ); + await page + .getByRole('button', { name: 'Activate release', exact: true }) + .click(); + const response = await activation; + expect(response.request().postDataJSON()).toEqual({ + releaseId: release.id, + expectedActivationRevision: expectedRevision, + }); + const state = await okJson( + response, + 'Activate captured release' + ); + expect(state.activeReleaseId).toBe(release.id); + expect(state.activationRevision).toBe(expectedRevision + 1); + await expect(page.getByTestId('ontology-active-release')).toHaveText( + 'Active' + ); + await expect( + page.getByRole('button', { name: 'Activate release', exact: true }) + ).toBeDisabled(); +}; + +const cleanReviews = async (api: APIRequestContext, tasks: string[]) => { + for (const id of tasks) { + const task = await okJson( + await api.get(`/api/v1/tasks/${id}`), + 'Read fixture review' + ); + if (!task.resolution) { + await okJson( + await api.post(`/api/v1/tasks/${id}/close`, { data: {} }), + 'Close fixture review' + ); + } + await deleteFixtureEntity(api, `/api/v1/tasks/${id}?hardDelete=true`); + } +}; + +test.describe( + 'Native ontology review', + { tag: ['@Features', '@Governance'] }, + () => { + for (const status of [403, 404]) { + test(`returns to glossary Studio when an ontology deep link fails with ${status}`, async ({ + freshUserPage: { page }, + }) => { + const ontologyId = fullUuid(); + const ontologyPath = `/api/v1/ontologies/${ontologyId}`; + await page.route(`**${ontologyPath}?*`, (route) => + route.fulfill({ status, json: { message: 'Ontology unavailable' } }) + ); + const loading = page.waitForResponse( + (response) => new URL(response.url()).pathname === ontologyPath + ); + await page.goto( + `/governance/ontology?ontology=${ontologyId}&draft=${fullUuid()}&source=context-center` + ); + + expect((await loading).status()).toBe(status); + await expect(page).toHaveURL( + /\/governance\/ontology\?source=context-center$/ + ); + await expect(page.getByTestId('mode-tab-view')).toHaveAttribute( + 'aria-selected', + 'true' + ); + await expect(page.getByTestId('ontology-explorer')).toBeVisible(); + }); + } + + test('reviews frozen release contents and blocks approval when they cannot be loaded', async ({ + freshUserPage: { page, user }, + }, testInfo) => { + test.slow(); + const api = await getWorkerAdminAPIContext(); + const name = `pw_ontology_review_${uuid()}`; + const ontology = await okJson( + await api.post('/api/v1/ontologies', { + data: { + name, + iri: `https://example.org/${name}`, + namespace: `https://example.org/${name}/`, + reviewers: [{ id: user.responseData.id, type: 'user' }], + }, + }), + 'Create reviewed ontology' + ); + const tasks: string[] = []; + + try { + const concept = await okJson( + await api.post('/api/v1/ontologyConcepts', { + data: { + name: 'Customer', + ontology: ontology.fullyQualifiedName, + conceptType: 'OWL_CLASS', + description: 'Submitted customer definition', + }, + }), + 'Create ontology concept' + ); + const state = await okJson( + await api.get(`/api/v1/ontologies/${ontology.id}/state`), + 'Read ontology revision' + ); + const release = await okJson( + await api.post(`/api/v1/ontologies/${ontology.id}/releases`, { + data: { expectedWorkingRevision: state.workingRevision }, + }), + 'Capture ontology release' + ); + const releaseTask = await submitReview( + api, + 'ontologyRelease', + release.fullyQualifiedName, + tasks + ); + + await test.step('Missing submitted content fails visibly and can be retried', async () => { + const versionPath = `**/api/v1/ontologyReleases/${release.id}/versions/*`; + await page.route(versionPath, (route) => + route.fulfill({ + status: 404, + json: { message: 'Version unavailable' }, + }) + ); + await page.goto(`/tasks/${releaseTask.id}`); + const error = page.getByRole('alert').filter({ + hasText: 'The submitted version could not be loaded', + }); + await expect(error).toBeVisible(); + await expect(page.getByTestId('task-approve')).toHaveCount(0); + await expect(page.getByTestId('code-mirror-container')).toHaveCount( + 0 + ); + await page.unroute(versionPath); + await error + .getByRole('button', { name: 'Retry', exact: true }) + .click(); + await page + .getByTestId('ontology-review-panel') + .getByRole('button', { name: 'Customer', exact: true }) + .click(); + await expect(page.getByTestId('code-mirror-container')).toContainText( + 'Submitted customer definition' + ); + }); + + await test.step('Release review keeps the captured content after a draft edit', async () => { + const updated = await okJson( + await api.patch(`/api/v1/ontologyConcepts/${concept.id}`, { + headers: { 'Content-Type': 'application/json-patch+json' }, + data: [ + { + op: 'replace', + path: '/description', + value: 'Later draft definition', + }, + ], + }), + 'Edit the working concept' + ); + expect(updated.description).toBe('Later draft definition'); + + await page.goto(`/tasks/${releaseTask.id}`); + const review = page.getByTestId('ontology-review-panel'); + await review + .getByRole('button', { name: 'Customer', exact: true }) + .click(); + const definition = review.getByTestId('code-mirror-container'); + await expect(definition).toContainText( + 'Submitted customer definition' + ); + await expect(definition).not.toContainText('Later draft definition'); + await page.screenshot({ + path: testInfo.outputPath('captured-review.png'), + fullPage: true, + }); + + await approveReview(page, releaseTask); + const approved = await okJson( + await api.get(`/api/v1/ontologyReleases/${release.id}`), + 'Read approved release' + ); + expect(approved.entityStatus).toBe('Approved'); + const currentState = await okJson( + await api.get(`/api/v1/ontologies/${ontology.id}/state`), + 'Read publication state' + ); + expect(currentState.activeReleaseId).toBeUndefined(); + }); + } finally { + await cleanReviews(api, tasks); + await deleteFixtureEntity( + api, + `/api/v1/ontologies/${ontology.id}?recursive=true` + ); + } + }); + } +); + +test.describe( + 'Native ontology publication', + { tag: ['@Features', '@Governance'] }, + () => { + test('captures, reviews, activates and rolls back sealed releases in Studio', async ({ + isolatedUserPage: author, + freshUserPage: { page: reviewer, user }, + }, testInfo) => { + test.slow(); + const pageErrors: string[] = []; + author.on('pageerror', (error) => pageErrors.push(error.message)); + reviewer.on('pageerror', (error) => pageErrors.push(error.message)); + const api = await getWorkerAdminAPIContext(); + const name = `pw_publication_${uuid()}`; + const ontology = await okJson( + await api.post('/api/v1/ontologies', { + data: { + name, + iri: `https://example.org/${name}`, + namespace: `https://example.org/${name}/`, + reviewers: [{ id: user.responseData.id, type: 'user' }], + }, + }), + 'Create publication fixture' + ); + const tasks: string[] = []; + const releases: OntologyRelease[] = []; + try { + const concept = await okJson( + await api.post('/api/v1/ontologyConcepts', { + data: { + name: 'Customer', + ontology: ontology.fullyQualifiedName, + conceptType: 'OWL_CLASS', + description: 'First published definition', + }, + }), + 'Create publication concept' + ); + + for (const description of [ + 'First published definition', + 'Second published definition', + ]) { + await openPublication(author, ontology); + const capture = author.waitForResponse( + (response) => + response + .url() + .endsWith(`/api/v1/ontologies/${ontology.id}/releases`) && + response.request().method() === 'POST' + ); + await author + .getByRole('button', { name: 'Capture release', exact: true }) + .click(); + const release = await okJson( + await capture, + 'Capture release in Studio' + ); + expect(release.baseReleaseId).toBe(releases.at(-1)?.id); + releases.push(release); + expect(release.entityStatus).toBe('Draft'); + await expect( + author.getByRole('button', { + name: 'Activate release', + exact: true, + }) + ).toBeDisabled(); + await expect(author.getByRole('alert')).toContainText( + 'Logical consistency has not been checked' + ); + + const submission = author.waitForResponse( + (response) => + new URL(response.url()).pathname === '/api/v1/tasks' && + response.request().method() === 'POST' + ); + await author + .getByRole('button', { name: 'Request approval', exact: true }) + .click(); + const task = await okJson( + await submission, + 'Submit captured release for workflow approval' + ); + tasks.push(task.id); + await expect(author).toHaveURL(new RegExp(`/tasks/${task.id}$`)); + await expect + .poll(async () => { + const pending = await okJson( + await api.get(`/api/v1/tasks/${task.id}`), + 'Read workflow task' + ); + return pending.availableTransitions?.length; + }) + .toBe(2); + + await okJson( + await api.patch(`/api/v1/ontologyConcepts/${concept.id}`, { + headers: { 'Content-Type': 'application/json-patch+json' }, + data: [ + { + op: 'replace', + path: '/description', + value: 'Second published definition', + }, + ], + }), + 'Edit draft independently of review' + ); + await reviewer.goto(`/tasks/${task.id}`); + await reviewer + .getByTestId('ontology-review-panel') + .getByRole('button', { name: 'Customer', exact: true }) + .click(); + await expect( + reviewer + .getByTestId('code-mirror-container') + .filter({ hasText: description }) + ).toContainText(description); + await approveReview(reviewer, task); + const beforeActivation = await okJson( + await api.get(`/api/v1/ontologies/${ontology.id}/state`), + 'Read approved publication state' + ); + expect(beforeActivation.activationRevision).toBe(releases.length - 1); + + await openPublication(author, ontology); + await author.getByLabel('Ontology release', { exact: true }).click(); + await author.getByTestId(`tree-node-${release.id}`).click(); + await activateRelease(author, ontology, release, releases.length - 1); + } + + const [firstRelease] = releases; + await author.reload(); + await author.getByTestId('mode-tab-releases').click(); + await expect(author.getByTestId('ontology-active-release')).toHaveText( + 'Active' + ); + await author.getByLabel('Ontology release', { exact: true }).click(); + await author.getByTestId(`tree-node-${firstRelease.id}`).click(); + await activateRelease(author, ontology, firstRelease, 2); + await author + .getByTestId('ontology-release-definition') + .getByRole('button', { name: 'Customer', exact: true }) + .click(); + await expect(author.getByTestId('code-mirror-container')).toContainText( + 'First published definition' + ); + await author.screenshot({ + path: testInfo.outputPath('studio-release-rollback.png'), + fullPage: true, + }); + await reviewer.goto(`/governance/ontology?ontology=${ontology.id}`); + await reviewer.getByTestId('mode-tab-query').click(); + await fillCodeEditor( + reviewer, + reviewer.getByTestId('ontology-sparql-editor'), + `SELECT ?description WHERE { <${concept.iri}> ?description }` + ); + const queried = reviewer.waitForResponse( + (response) => + new URL(response.url()).pathname === + `/api/v1/ontologies/${ontology.id}/sparql` && + response.request().method() === 'POST' + ); + await reviewer.getByTestId('ontology-sparql-run').click(); + expect((await queried).ok()).toBeTruthy(); + await expect( + reviewer.getByTestId('ontology-sparql-result') + ).toContainText('First published definition'); + await expect( + reviewer.getByTestId('ontology-sparql-result') + ).not.toContainText('Second published definition'); + await deleteFixtureEntity( + api, + `/api/v1/ontologyConcepts/${concept.id}` + ); + for (const [format, label, extension] of [ + ['turtle', 'Turtle (.ttl)', 'ttl'], + ['rdfxml', 'RDF/XML (.rdf)', 'rdf'], + ['ntriples', 'N-Triples (.nt)', 'nt'], + ['jsonld', 'JSON-LD', 'jsonld'], + ]) { + const exported = author.waitForResponse((response) => { + const url = new URL(response.url()); + + return ( + url.pathname === + `/api/v1/ontologyReleases/${firstRelease.id}/export` && + url.searchParams.get('format') === format + ); + }); + const downloaded = author.waitForEvent('download'); + await author + .getByTestId('ontology-release-definition') + .getByRole('button', { name: 'Export', exact: true }) + .click(); + if (format === 'turtle') { + await testInfo.attach('native-release-export', { + body: await author + .getByRole('menu', { name: 'Export', exact: true }) + .screenshot({ animations: 'disabled' }), + contentType: 'image/png', + }); + } + await author + .getByRole('menuitem', { name: label, exact: true }) + .click(); + const response = await exported; + expect(response.ok()).toBeTruthy(); + const download = await downloaded; + expect(download.suggestedFilename()).toBe( + `ontology-release-${firstRelease.id}.${extension}` + ); + const path = testInfo.outputPath(download.suggestedFilename()); + await download.saveAs(path); + const content = await readFile(path, 'utf8'); + expect(content).toBe(await response.text()); + expect(content).toContain(concept.iri); + expect(content).toContain('First published definition'); + expect(content).not.toContain('Second published definition'); + } + expect(pageErrors).toEqual([]); + } finally { + await cleanReviews(api, tasks); + await deleteFixtureEntity( + api, + `/api/v1/ontologies/${ontology.id}?recursive=true` + ); + } + }); + } +); + +test( + 'authors a native ontology and concept in Studio with independent reviewers', + { tag: ['@Features', '@Governance'] }, + async ( + { isolatedUserPage: author, freshUserPage: { user: reviewer } }, + testInfo + ) => { + test.slow(); + const api = await getWorkerAdminAPIContext(); + const name = `pw_authoring_${uuid()}`; + let ontology: Ontology | undefined; + try { + await author.goto('/governance/ontology'); + await author.getByTestId('ontology-create').click(); + const form = author.getByTestId('ontology-create-form'); + await form.getByRole('textbox', { name: 'Name', exact: true }).fill(name); + await form + .getByRole('textbox', { name: 'Ontology IRI', exact: true }) + .fill(`urn:${name}`); + await form + .getByRole('textbox', { name: 'Namespace', exact: true }) + .fill('relative/path'); + await form.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(form).toContainText('Enter a valid concept IRI.'); + await form + .getByRole('textbox', { name: 'Namespace', exact: true }) + .fill(`urn:${name}:`); + await form + .getByRole('combobox', { name: 'Reviewers', exact: true }) + .fill(reviewer.responseData.name); + await author + .getByRole('option') + .filter({ + hasText: + reviewer.responseData.displayName ?? reviewer.responseData.name, + }) + .click(); + await form + .getByRole('combobox', { name: 'Reviewers', exact: true }) + .press('Escape'); + const created = author.waitForResponse( + (response) => + new URL(response.url()).pathname === '/api/v1/ontologies' && + response.request().method() === 'POST' + ); + await form.getByRole('button', { name: 'Save', exact: true }).click(); + ontology = await okJson( + await created, + 'Create ontology in Studio' + ); + expect(ontology.reviewers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: reviewer.responseData.id, + type: 'user', + }), + ]) + ); + expect(ontology.owners).toHaveLength(1); + + await author.getByTestId('mode-tab-model').click(); + await author.getByTestId('ontology-add-concept').click(); + const editor = author.getByTestId('ontology-concept-editor'); + await editor + .getByRole('textbox', { name: 'Name', exact: true }) + .fill('Customer'); + await editor + .getByRole('textbox', { name: 'Description', exact: true }) + .fill('Native customer definition'); + await expect(editor.getByRole('button', { name: /Type$/ })).toContainText( + 'OWL_CLASS' + ); + const conceptCreated = author.waitForResponse( + (response) => + new URL(response.url()).pathname === '/api/v1/ontologyConcepts' && + response.request().method() === 'POST' + ); + await editor.getByRole('button', { name: 'Save', exact: true }).click(); + const concept = await okJson( + await conceptCreated, + 'Create concept in Studio' + ); + expect(concept.ontology.id).toBe(ontology.id); + expect(concept.iri).toMatch(new RegExp(`^urn:${name}:`)); + await expect( + editor.getByRole('textbox', { name: 'Concept IRI', exact: true }) + ).toHaveValue(concept.iri); + await expect( + editor.getByRole('textbox', { name: 'Name', exact: true }) + ).toBeDisabled(); + await editor + .getByRole('textbox', { name: 'Display Name', exact: true }) + .fill('Customers'); + await editor + .getByRole('button', { name: 'Add mapping', exact: true }) + .click(); + const mappingEditor = editor.getByTestId('ontology-concept-mapping'); + const mapping = { + conceptIri: `https://example.org/${name}/Customer`, + mappingType: 'EXACT_MATCH', + schemeIri: `https://example.org/${name}/vocabulary`, + source: 'External vocabulary', + }; + await mappingEditor + .getByRole('textbox', { name: 'Concept IRI', exact: true }) + .fill(mapping.conceptIri); + await mappingEditor + .getByRole('textbox', { name: 'Scheme IRI', exact: true }) + .fill(mapping.schemeIri); + await mappingEditor + .getByRole('textbox', { name: 'Source', exact: true }) + .fill(mapping.source); + const edited = author.waitForResponse( + (response) => + response.url().endsWith(`/api/v1/ontologyConcepts/${concept.id}`) && + response.request().method() === 'PATCH' + ); + await editor.getByRole('button', { name: 'Save', exact: true }).click(); + const updated = await okJson( + await edited, + 'Update concept in Studio' + ); + expect(updated.iri).toBe(concept.iri); + expect(updated.displayName).toBe('Customers'); + expect(updated.conceptMappings).toEqual([mapping]); + await author.reload(); + await author.getByTestId('mode-tab-model').click(); + await author + .getByRole('button', { name: 'Customers', exact: true }) + .click(); + await expect( + editor.getByRole('textbox', { name: 'Description', exact: true }) + ).toHaveValue('Native customer definition'); + await expect( + mappingEditor.getByRole('textbox', { name: 'Concept IRI', exact: true }) + ).toHaveValue(mapping.conceptIri); + await expect( + mappingEditor.getByRole('textbox', { name: 'Scheme IRI', exact: true }) + ).toHaveValue(mapping.schemeIri); + await expect( + mappingEditor.getByRole('textbox', { name: 'Source', exact: true }) + ).toHaveValue(mapping.source); + await testInfo.attach('native-concept-mappings', { + body: await mappingEditor.screenshot(), + contentType: 'image/png', + }); + await mappingEditor + .getByRole('button', { name: 'Remove', exact: true }) + .click(); + const mappingRemoved = author.waitForResponse( + (response) => + response.url().endsWith(`/api/v1/ontologyConcepts/${concept.id}`) && + response.request().method() === 'PATCH' + ); + await editor.getByRole('button', { name: 'Save', exact: true }).click(); + const withoutMapping = await okJson( + await mappingRemoved, + 'Remove native concept mapping' + ); + expect(withoutMapping.conceptMappings).toEqual([]); + expect(withoutMapping.iri).toBe(concept.iri); + await expect(mappingEditor).toHaveCount(0); + await author.getByTestId('ontology-model-property').click(); + await author.getByTestId('ontology-add-property').click(); + const propertyEditor = author.getByTestId('ontology-property-editor'); + await propertyEditor + .getByRole('textbox', { name: 'Name', exact: true }) + .fill('status'); + await propertyEditor.getByRole('button', { name: /Type$/ }).click(); + await author.getByRole('option', { name: 'DATA', exact: true }).click(); + await propertyEditor + .getByRole('textbox', { name: 'Domain classes', exact: true }) + .fill(concept.iri); + await propertyEditor + .getByRole('textbox', { name: 'Range', exact: true }) + .fill('http://www.w3.org/2001/XMLSchema#string'); + await propertyEditor + .getByRole('button', { name: 'Add Value', exact: true }) + .click(); + await propertyEditor + .getByRole('textbox', { name: 'Value', exact: true }) + .fill(' active '); + await propertyEditor + .getByRole('textbox', { name: 'Data Type', exact: true }) + .fill('http://www.w3.org/2001/XMLSchema#string'); + const propertyCreated = author.waitForResponse( + (response) => + new URL(response.url()).pathname === '/api/v1/ontologyProperties' && + response.request().method() === 'POST' + ); + await propertyEditor + .getByRole('button', { name: 'Save', exact: true }) + .click(); + const property = await okJson( + await propertyCreated, + 'Create native data property in Studio' + ); + expect(property.ontology.id).toBe(ontology.id); + expect(property.kind).toBe('DATA'); + expect(property.domain).toEqual([concept.iri]); + expect(property.enumValues).toEqual([ + { + value: ' active ', + datatypeIri: 'http://www.w3.org/2001/XMLSchema#string', + }, + ]); + await propertyEditor + .getByRole('textbox', { name: 'Display Name', exact: true }) + .fill('Customer status'); + const propertyPatched = author.waitForResponse( + (response) => + response + .url() + .endsWith(`/api/v1/ontologyProperties/${property.id}`) && + response.request().method() === 'PATCH' + ); + await propertyEditor + .getByRole('button', { name: 'Save', exact: true }) + .click(); + const savedProperty = await okJson( + await propertyPatched, + 'Update native property in Studio' + ); + expect(savedProperty.iri).toBe(property.iri); + expect(savedProperty.enumValues).toEqual(property.enumValues); + await author.reload(); + await author.getByTestId('mode-tab-model').click(); + await author.getByTestId('ontology-model-property').click(); + await author + .getByRole('button', { name: 'Customer status', exact: true }) + .click(); + await expect( + propertyEditor.getByRole('textbox', { name: 'Value', exact: true }) + ).toHaveValue(' active '); + for (const permanent of [false, true]) { + await test.step(`Delete the native property (permanent: ${permanent})`, async () => { + await author + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + const modal = author.getByTestId('delete-modal'); + if (permanent) { + await modal.getByTestId('hard-delete').click(); + } + const deleted = author.waitForResponse( + (response) => + new URL(response.url()).pathname === + `/api/v1/ontologyProperties/${property.id}` && + response.request().method() === 'DELETE' + ); + await modal.getByTestId('confirm-button').click(); + const response = await deleted; + expect(response.status()).toBe(200); + expect(new URL(response.url()).searchParams.get('hardDelete')).toBe( + String(permanent) + ); + await expect(propertyEditor).not.toBeVisible(); + const stored = await api.get( + `/api/v1/ontologyProperties/${property.id}?include=all` + ); + if (permanent) { + expect(stored.status()).toBe(404); + } else { + expect( + (await okJson(stored, 'Read deleted property')) + .deleted + ).toBe(true); + await okJson( + await api.put('/api/v1/ontologyProperties/restore', { + data: { id: property.id }, + }), + 'Restore property before permanent deletion' + ); + await author.reload(); + await author.getByTestId('mode-tab-model').click(); + await author.getByTestId('ontology-model-property').click(); + await author + .getByRole('button', { name: 'Customer status', exact: true }) + .click(); + } + }); + } + const parent = await okJson( + await api.post('/api/v1/ontologyConcepts', { + data: { + name: 'Party', + ontology: ontology.fullyQualifiedName, + conceptType: 'OWL_CLASS', + }, + }), + 'Declare the superclass' + ); + await author.getByTestId('ontology-model-axiom').click(); + await author.getByTestId('ontology-add-axiom').click(); + const axiom = author.getByTestId('ontology-axiom-panel'); + await axiom + .getByRole('textbox', { name: 'Name', exact: true }) + .fill('CustomerIsParty'); + await axiom + .getByRole('textbox', { name: 'Display Name', exact: true }) + .fill('Customer is a Party'); + await axiom + .getByRole('textbox', { name: 'Description', exact: true }) + .fill('Customers are parties'); + await axiom + .getByRole('textbox', { name: 'Subject', exact: true }) + .fill(concept.iri); + await axiom + .getByRole('textbox', { name: /^Concept IRI/ }) + .fill(parent.iri); + await expect(axiom.getByTestId('ontology-axiom-submit')).toBeEnabled(); + const axiomCreated = author.waitForResponse( + (response) => + new URL(response.url()).pathname === '/api/v1/ontologyAxioms' && + response.request().method() === 'POST' + ); + await axiom.getByTestId('ontology-axiom-submit').click(); + const hierarchy = await okJson( + await axiomCreated, + 'Create the native subclass axiom' + ); + expect(hierarchy.ontology.id).toBe(ontology.id); + await expect(author.getByText('Saved', { exact: true })).toBeVisible(); + await author.getByTestId('mode-tab-graph').click(); + await expect(author.locator('.ontology-g6-container')).toBeVisible(); + expect(await readNodePositions(author)).toEqual( + expect.objectContaining({ + [concept.id]: expect.any(Object), + [parent.id]: expect.any(Object), + }) + ); + expect(await readGraphEdges(author)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: concept.id, + to: parent.id, + relationType: 'subClassOf', + }), + ]) + ); + await author.getByTestId('submode-tab-tree').click(); + await expect( + author.getByTestId(`ontology-tree-term-${concept.id}`) + ).toBeVisible(); + await expect( + author.getByTestId(`ontology-tree-term-${parent.id}`) + ).toBeVisible(); + await author.screenshot({ + path: testInfo.outputPath('native-definition-authoring.png'), + fullPage: true, + }); + } finally { + if (ontology) { + await deleteFixtureEntity( + api, + `/api/v1/ontologies/${ontology.id}?recursive=true` + ); + } + } + } +); + +test( + 'reviews a native memory draft through its Context Center link and captures the applied definition', + { tag: ['@Features', '@Governance'] }, + async ({ isolatedUserPage: page }) => { + const api = await getWorkerAdminAPIContext(); + const name = `pw_native_memory_${uuid()}`; + const ontology = await okJson( + await api.post('/api/v1/ontologies', { + data: { + name, + iri: `urn:${name}`, + namespace: `urn:${name}:`, + }, + }), + 'Create native memory ontology' + ); + let memory: ContextMemory | undefined; + let draft: OntologyChangeSet | undefined; + try { + memory = await okJson( + await api.post('/api/v1/contextMemories', { + data: { + name: `${name}_source`, + question: 'Who is a customer?', + answer: 'A customer buys products.', + entityStatus: 'Approved', + }, + }), + 'Create source memory' + ); + const state = await okJson( + await api.get(`/api/v1/ontologies/${ontology.id}/state`), + 'Read working revision' + ); + const conceptId = fullUuid(); + const iri = `urn:${name}:Customer`; + draft = await okJson( + await api.post('/api/v1/ontologyChangeSets', { + data: { + name: `${name}_draft`, + displayName: 'Native memory proposal', + description: 'A customer definition proposed from memory', + ontology: ontology.fullyQualifiedName, + baseWorkingRevision: state.workingRevision, + operations: [ + { + id: fullUuid(), + operationType: 'UPSERT_CONCEPT', + state: 'ACTIVE', + sourceMemoryIds: [memory.id], + concept: { + id: conceptId, + name: 'Customer', + fullyQualifiedName: `${ontology.fullyQualifiedName}.Customer`, + ontology: { id: ontology.id, type: 'ontology' }, + iri, + conceptType: 'OWL_CLASS', + description: 'A customer buys products.', + }, + }, + ], + }, + }), + 'Create native memory draft' + ); + await page.goto(`/governance/ontology?draft=${draft.id}`); + await expect( + page.getByTestId('ontology-memory-definition') + ).toContainText(iri); + const submitted = page.waitForResponse( + (response) => + new URL(response.url()).pathname === + `/api/v1/ontologyChangeSets/${draft?.id}/submit` && + response.request().method() === 'POST' + ); + await page.getByTestId('ontology-memory-submit').click(); + expect( + ( + await okJson( + await submitted, + 'Submit native memory draft' + ) + ).state + ).toBe('SUBMITTED'); + const applied = page.waitForResponse( + (response) => + new URL(response.url()).pathname === + `/api/v1/ontologyChangeSets/${draft?.id}/apply` && + response.request().method() === 'POST' + ); + await page.getByTestId('ontology-memory-apply').click(); + expect( + ( + await okJson( + await applied, + 'Apply native memory draft' + ) + ).state + ).toBe('APPLIED'); + await expect( + page.getByTestId(`ontology-memory-draft-${draft.id}`) + ).toBeHidden(); + await selectOntology(page, ontology); + await page.getByTestId('mode-tab-model').click(); + await page.getByRole('button', { name: 'Customer', exact: true }).click(); + await expect( + page + .getByTestId('ontology-concept-editor') + .getByRole('textbox', { name: 'Description', exact: true }) + ).toHaveValue('A customer buys products.'); + await page.getByTestId('mode-tab-releases').click(); + const captured = page.waitForResponse( + (response) => + new URL(response.url()).pathname === + `/api/v1/ontologies/${ontology.id}/releases` && + response.request().method() === 'POST' + ); + await page + .getByRole('button', { name: 'Capture release', exact: true }) + .click(); + expect( + ( + await okJson( + await captured, + 'Capture memory-derived working model' + ) + ).memberCount + ).toBe(2); + } finally { + if (draft) + await deleteFixtureEntity( + api, + `/api/v1/ontologyChangeSets/${draft.id}?hardDelete=true` + ); + if (memory) + await deleteFixtureEntity( + api, + `/api/v1/contextMemories/${memory.id}?hardDelete=true` + ); + const archived = await deleteFixtureEntity( + api, + `/api/v1/ontologies/${ontology.id}?recursive=true` + ); + expect(archived.ok()).toBeTruthy(); + } + } +); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/TaskFeedCard/TaskFeedCardFromTask.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/TaskFeedCard/TaskFeedCardFromTask.component.test.tsx index 7358ea581706..9167033b9a87 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/TaskFeedCard/TaskFeedCardFromTask.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/TaskFeedCard/TaskFeedCardFromTask.component.test.tsx @@ -329,4 +329,83 @@ describe('TaskFeedCardFromTask', () => { expect(screen.queryByTestId('approve-button')).not.toBeInTheDocument(); expect(screen.queryByTestId('reject-button')).not.toBeInTheDocument(); }); + + const ontologyReview: Task = { + ...MOCK_TASK, + type: TaskEntityType.RequestApproval, + category: TaskCategory.Approval, + about: { id: 'release-id', type: 'ontologyRelease' }, + assignees: [{ id: 'current-user-id', type: 'user', name: 'current-user' }], + payload: { proposedChanges: { description: 'Captured definition' } }, + }; + + it.each([ + { availableTransitions: [] }, + { + availableTransitions: [ + { + id: 'approve', + label: 'Approve', + targetStageId: 'approved', + targetTaskStatus: TaskEntityStatus.Approved, + }, + ], + }, + { + availableTransitions: [ + { + id: 'reject', + label: 'Reject', + requiresComment: true, + targetStageId: 'rejected', + targetTaskStatus: TaskEntityStatus.Rejected, + }, + ], + }, + ])( + 'requires opening a release review even when a quick transition is available', + async ({ availableTransitions }) => { + (isTagsTaskType as jest.Mock).mockReturnValue(false); + await act(async () => { + render( + , + { + wrapper: MemoryRouter, + } + ); + }); + + expect(screen.queryByTestId('approve-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('reject-button')).not.toBeInTheDocument(); + } + ); + + it('preserves quick actions for non-ontology approval tasks', async () => { + await act(async () => { + render( + , + { wrapper: MemoryRouter } + ); + }); + + expect(screen.getByTestId('approve-button')).toBeInTheDocument(); + expect(screen.getByTestId('reject-button')).toBeInTheDocument(); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/TaskFeedCard/TaskFeedCardFromTask.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/TaskFeedCard/TaskFeedCardFromTask.component.tsx index ea886e410169..a5aab2271d27 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/TaskFeedCard/TaskFeedCardFromTask.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/TaskFeedCard/TaskFeedCardFromTask.component.tsx @@ -67,6 +67,10 @@ import { isTaskPendingFurtherApproval, } from '../../../utils/TaskNavigationUtils'; import { getNormalizedTaskPayload } from '../../../utils/TaskPayloadUtils'; +import { + getTaskResolveBody, + isOntologyReviewTask, +} from '../../../utils/TaskUtils'; import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; import { CARD_CONTAINER_CLASS_NAME, @@ -242,15 +246,18 @@ const TaskFeedCardFromTask = ({ const isApprovalWorkflowTask = isTaskApprovalRequest || isTaskRecognizerFeedbackApproval; - const getTransitionId = (resolutionType: TaskResolutionType) => + const getTransition = (resolutionType: TaskResolutionType) => task.availableTransitions?.find( (transition) => transition.resolutionType === resolutionType - )?.id ?? + ) ?? task.availableTransitions?.find((transition) => resolutionType === TaskResolutionType.Rejected ? transition.id === 'reject' : transition.id === 'approve' - )?.id; + ); + + const canQuickResolve = () => + task.status === TaskEntityStatus.Open && !isOntologyReviewTask(task); const updateTaskData = async ( newValue: string, @@ -260,11 +267,14 @@ const TaskFeedCardFromTask = ({ return; } try { - const updatedTask = await resolveTaskAPI(task.id, { - transitionId: getTransitionId(resolutionType), - resolutionType, - newValue, - }); + const updatedTask = await resolveTaskAPI( + task.id, + getTaskResolveBody(task, { + transitionId: getTransition(resolutionType)?.id, + resolutionType, + newValue, + }) + ); showSuccessToast( isTaskPendingFurtherApproval(updatedTask) ? 'Vote recorded.' @@ -376,7 +386,7 @@ const TaskFeedCardFromTask = ({ {!isTaskTestCaseResult && hasEditAccess && !isSuggestionEmpty && ( - {task.status === TaskEntityStatus.Open && ( + {canQuickResolve() && ( +); +jest.mock( + '../governance/ontology/OntologyAxiomWorkspace/OntologyAxiomWorkspace', + () => + ({ ontology }: { ontology: { id: string } }) => + {ontology.id} +); -const mockCreate = createOntologyAxiom as jest.MockedFunction< - typeof createOntologyAxiom ->; -const mockValidate = validateOntologyAxiom as jest.MockedFunction< - typeof validateOntologyAxiom ->; -const GLOSSARY: Glossary = { - description: 'Governed axiom', - displayName: 'Governed glossary', - fullyQualifiedName: 'GovernedGlossary', - id: 'glossary-id', - name: 'GovernedGlossary', -}; +it('selects a native ontology before opening the formal axiom workbench', () => { + render(); -describe('OntologyAxiomPanel', () => { - it('creates only after the live OWL profile guard accepts the typed axiom', async () => { - mockValidate.mockResolvedValue({ valid: true, violations: [] }); - mockCreate.mockResolvedValue({ - axiomType: AxiomType.SubclassOf, - description: GLOSSARY.description, - displayName: GLOSSARY.displayName ?? GLOSSARY.name, - entityStatus: EntityStatus.Draft, - expressions: [ - { - classIri: 'https://example.test/Class', - kind: ExpressionKind.NamedClass, - }, - ], - fullyQualifiedName: 'GovernedGlossary.axiom-GovernedGlossary', - glossary: { id: GLOSSARY.id, type: 'glossary' }, - id: 'axiom-id', - name: 'axiom-GovernedGlossary', - provenance: Provenance.Manual, - subjectIri: 'https://example.test/Subject', - }); - render(); + expect(screen.queryByText('native')).not.toBeInTheDocument(); - fireEvent.change(screen.getByRole('textbox', { name: 'label.subject' }), { - target: { value: 'https://example.test/Subject' }, - }); - fireEvent.change( - screen.getByRole('textbox', { name: /label\.concept-iri/ }), - { target: { value: 'https://example.test/Class' } } - ); - fireEvent.click(screen.getByTestId('ontology-axiom-validate')); + fireEvent.click(screen.getByRole('button', { name: 'select' })); - await screen.findByText('label.validation-passed'); - fireEvent.click(screen.getByTestId('ontology-axiom-submit')); - - await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - axiomType: AxiomType.SubclassOf, - entityStatus: EntityStatus.Draft, - expressions: [ - { - classIri: 'https://example.test/Class', - kind: ExpressionKind.NamedClass, - }, - ], - glossary: GLOSSARY.fullyQualifiedName, - provenance: Provenance.Manual, - subjectIri: 'https://example.test/Subject', - }) - ); - }); + expect(screen.getByText('native')).toBeInTheDocument(); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyAxiomPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyAxiomPanel.tsx index 5c7172ae0b69..52ce7900850d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyAxiomPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyAxiomPanel.tsx @@ -11,409 +11,22 @@ * limitations under the License. */ -import { - Alert, - Button, - Card, - FieldProp, - FieldTypes, - getField, - HookForm, - Input, - Select, - Typography, -} from '@openmetadata/ui-core-components'; -import { Key, useEffect, useRef, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { - AxiomType, - ExpressionKind, -} from '../../generated/api/data/createOntologyAxiom'; -import { OntologyProfileReport } from '../../generated/api/data/ontologyProfileReport'; -import { Glossary } from '../../generated/entity/data/glossary'; -import { OntologyAxiom } from '../../generated/entity/data/ontologyAxiom'; -import { - createOntologyAxiom, - validateOntologyAxiom, -} from '../../rest/ontologyAPI'; -import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; -import OntologyExpressionEditor, { - defaultExpression, - getExpressionRenderKey, -} from './OntologyExpressionEditor'; -import { - buildOntologyAxiomRequest, - EMPTY_AXIOM_FORM, - isOntologyAxiomReady, - OntologyAxiomFormState, -} from './OntologyModelingWorkbench.utils'; +import { Box } from '@openmetadata/ui-core-components'; +import { useState } from 'react'; +import { Ontology } from '../../generated/entity/data/ontology'; +import OntologyAxiomWorkspace from '../governance/ontology/OntologyAxiomWorkspace/OntologyAxiomWorkspace'; +import OntologyPicker from '../governance/ontology/OntologyPicker/OntologyPicker'; -interface OntologyAxiomPanelProps { - glossary: Glossary; -} - -const LIVE_VALIDATION_DELAY_MILLIS = 450; - -const isClassExpressionAxiom = (axiomType: AxiomType) => - [ - AxiomType.ClassAssertion, - AxiomType.DisjointWith, - AxiomType.EquivalentClass, - AxiomType.SubclassOf, - ].includes(axiomType); - -const resetShape = ( - form: ReturnType>, - axiomType: AxiomType -) => { - form.setValue( - 'expressions', - isClassExpressionAxiom(axiomType) - ? [defaultExpression(ExpressionKind.NamedClass)] - : [] - ); - form.setValue('literalDatatypeIri', ''); - form.setValue('literalValue', ''); - form.setValue('propertyIri', ''); - form.setValue('targetIri', ''); -}; - -const axiomTypeLabel = ( - axiomType: AxiomType, - t: ReturnType['t'] -) => { - let label: string; - - switch (axiomType) { - case AxiomType.ClassAssertion: - label = t('label.classification'); - - break; - case AxiomType.DataPropertyAssertion: - label = t('label.custom-property'); - - break; - case AxiomType.DisjointWith: - label = t('label.conflict-resolution'); - - break; - case AxiomType.EquivalentClass: - label = t('label.exact-match'); - - break; - case AxiomType.ObjectPropertyAssertion: - label = t('label.relationship'); - - break; - case AxiomType.SubclassOf: - label = t('label.child-of'); - - break; - } - - return label; -}; - -const requiredField = ( - name: string, - label: string, - t: ReturnType['t'], - type: FieldTypes = FieldTypes.TEXT -): FieldProp => ({ - label, - name, - required: true, - rules: { required: t('label.field-required', { field: label }) }, - type, -}); - -const ProfileResult = ({ profile }: { profile: OntologyProfileReport }) => { - const { t } = useTranslation(); +const OntologyAxiomPanel = () => { + const [ontology, setOntology] = useState(); return ( - - {profile.violations.length ? ( -

    - {profile.violations.map((violation) => ( -
  • - {violation.message} -
  • - ))} -
- ) : null} - - ); -}; - -const OntologyAxiomPanel = ({ glossary }: OntologyAxiomPanelProps) => { - const { t } = useTranslation(); - const form = useForm({ - defaultValues: { - ...EMPTY_AXIOM_FORM, - expressions: [defaultExpression(ExpressionKind.NamedClass)], - }, - }); - const values = form.watch(); - const validationSequence = useRef(0); - const [profile, setProfile] = useState(); - const [created, setCreated] = useState(); - const [isValidating, setIsValidating] = useState(false); - const glossaryFullyQualifiedName = - glossary.fullyQualifiedName ?? glossary.name; - const validationKey = JSON.stringify(values); - const validationValues = useRef(values); - validationValues.current = values; - - useEffect(() => { - form.reset({ - ...EMPTY_AXIOM_FORM, - description: glossary.description, - displayName: glossary.displayName ?? glossary.name, - expressions: [defaultExpression(ExpressionKind.NamedClass)], - name: `axiom-${glossary.name}`, - }); - setCreated(undefined); - setProfile(undefined); - }, [ - form, - glossary.description, - glossary.displayName, - glossary.id, - glossary.name, - ]); - - useEffect(() => { - if (!isOntologyAxiomReady(validationValues.current)) { - setProfile(undefined); - - return; - } - const sequence = ++validationSequence.current; - const timeout = window.setTimeout(() => { - validateOntologyAxiom( - buildOntologyAxiomRequest( - glossaryFullyQualifiedName, - validationValues.current - ) - ) - .then((report) => { - if (sequence === validationSequence.current) { - setProfile(report); - } - }) - .catch(() => { - if (sequence === validationSequence.current) { - setProfile(undefined); - } - }); - }, LIVE_VALIDATION_DELAY_MILLIS); - - return () => window.clearTimeout(timeout); - }, [glossaryFullyQualifiedName, validationKey]); - - const validate = async (submitted = values) => { - setIsValidating(true); - let report: OntologyProfileReport | undefined; - try { - report = await validateOntologyAxiom( - buildOntologyAxiomRequest(glossaryFullyQualifiedName, submitted) - ); - setProfile(report); - } catch { - showErrorToast(t('server.unexpected-error')); - } finally { - setIsValidating(false); - } - - return report; - }; - - const submit = async (submitted: OntologyAxiomFormState) => { - const report = await validate(submitted); - if (report?.valid) { - try { - setCreated( - await createOntologyAxiom( - buildOntologyAxiomRequest(glossaryFullyQualifiedName, submitted) - ) - ); - showSuccessToast(t('label.created')); - } catch { - showErrorToast(t('server.unexpected-error')); - } - } - }; - - const selectAxiomType = (key: Key | null) => { - if (key) { - const axiomType = String(key) as AxiomType; - form.setValue('axiomType', axiomType, { shouldDirty: true }); - resetShape(form, axiomType); - setCreated(undefined); - } - }; - - const metadataFields: FieldProp[] = [ - requiredField('name', t('label.name'), t), - requiredField('displayName', t('label.display-name'), t), - requiredField( - 'description', - t('label.description'), - t, - FieldTypes.TEXTAREA - ), - requiredField('subjectIri', t('label.subject'), t), - ]; - const usesExpressions = isClassExpressionAxiom(values.axiomType); - const usesProperty = [ - AxiomType.DataPropertyAssertion, - AxiomType.ObjectPropertyAssertion, - ].includes(values.axiomType); - - return ( - - - - {t('label.ontology')} {t('label.constraint-plural')} - - -
- {metadataFields.slice(0, 2).map((field) => ( -
{getField(field)}
- ))} -
{getField(metadataFields[2])}
-
{getField(metadataFields[3])}
-
- - {usesProperty ? ( - - form.setValue('propertyIri', propertyIri) - } - /> - ) : null} - {values.axiomType === AxiomType.ObjectPropertyAssertion ? ( - form.setValue('targetIri', targetIri)} - /> - ) : null} - {values.axiomType === AxiomType.DataPropertyAssertion ? ( -
- - form.setValue('literalValue', literalValue) - } - /> - - form.setValue('literalDatatypeIri', literalDatatypeIri) - } - /> -
- ) : null} - - {usesExpressions ? ( -
- {values.expressions.map((expression, index) => ( - - form.setValue( - 'expressions', - values.expressions.map((current, position) => - position === index ? next : current - ), - { shouldDirty: true } - ) - } - onRemove={() => - form.setValue( - 'expressions', - values.expressions.filter( - (_, position) => position !== index - ), - { shouldDirty: true } - ) - } - /> - ))} - -
- ) : null} - - {profile ? : null} -
- - -
- {created ? ( - - ) : null} -
-
+ + + {ontology && ( + + )} + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyMemoryReviewPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyMemoryReviewPanel.test.tsx index ce9bd6c43ef3..f1754d11e0f0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyMemoryReviewPanel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyMemoryReviewPanel.test.tsx @@ -32,6 +32,10 @@ import { import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; import OntologyMemoryReviewPanel from './OntologyMemoryReviewPanel'; +jest.mock('../../hooks/useEntityPermissions/useEntityPermissions', () => ({ + useEntityPermissions: () => ({ canEditAll: true }), +})); + jest.mock('../../rest/ontologyAPI', () => ({ applyOntologyChangeSet: jest.fn(), discardOntologyChangeSet: jest.fn(), @@ -121,6 +125,54 @@ describe('OntologyMemoryReviewPanel', () => { } as Awaited>); }); + it('reviews a native concept with its ontology scope and source memory', async () => { + const nativeDraft: OntologyChangeSet = { + ...draft, + id: 'native-draft', + glossaries: [], + ontology: { id: 'native-id', type: 'ontology', name: 'Sales model' }, + baseWorkingRevision: 3, + operations: [ + { + ...draft.operations[0], + term: undefined, + operationType: OperationType.UpsertConcept, + concept: { + id: 'customer', + name: 'Customer', + iri: 'urn:Customer', + ontology: { id: 'native-id', type: 'ontology' }, + } as NonNullable, + }, + ], + }; + mockList.mockResolvedValue({ + data: [draft, nativeDraft], + paging: { total: 2 }, + }); + renderPanel( + + ); + + expect( + await screen.findByRole('heading', { name: 'Customer' }) + ).toBeInTheDocument(); + expect(screen.getByTestId('ontology-memory-definition')).toHaveTextContent( + 'urn:Customer' + ); + expect(screen.getAllByText(/Sales model/).length).toBeGreaterThan(0); + expect(screen.queryByText('inactive_customer')).not.toBeInTheDocument(); + expect( + await screen.findByRole('link', { name: 'Inactive customer' }) + ).toBeInTheDocument(); + }); + it('shows the persisted glossary, term, and a link to the source memory', async () => { renderPanel( void; } @@ -62,6 +65,14 @@ const STATE_LABEL_KEYS: Partial> = { const LINK_CLASS_NAME = 'tw:text-link tw:hover:underline'; const SOURCE_MEMORY_QUERY_KEY = 'ontologyDraftSourceMemory'; const SOURCE_MEMORY_STALE_TIME_MS = 60_000; +const DEFINITION_LABELS: Partial> = { + [OperationType.UpsertConcept]: 'label.concept', + [OperationType.DeleteConcept]: 'label.concept', + [OperationType.UpsertProperty]: 'label.property', + [OperationType.DeleteProperty]: 'label.property', + [OperationType.UpsertAxiom]: 'label.constraint', + [OperationType.DeleteAxiom]: 'label.constraint', +}; function stateLabel(state: OntologyChangeSetState, t: TFunction): string { const key = STATE_LABEL_KEYS[state]; @@ -69,7 +80,24 @@ function stateLabel(state: OntologyChangeSetState, t: TFunction): string { return key ? t(key) : state; } -function operationTypeLabel(type: OperationType, t: TFunction): string { +function operationTypeLabel( + operation: OntologyChangeOperation, + t: TFunction +): string { + const type = operation.operationType; + const definitionLabel = DEFINITION_LABELS[type]; + if (definitionLabel) { + const upsertAction = operation.targetId + ? 'label.edit-entity' + : 'label.new-entity'; + + return t( + type.startsWith('DELETE_') ? 'label.delete-entity' : upsertAction, + { + entity: t(definitionLabel), + } + ); + } switch (type) { case OperationType.CreateGlossary: return t('label.new-entity', { entity: t('label.glossary') }); @@ -100,12 +128,33 @@ function sourceMemoryIds(changeSet: OntologyChangeSet): string[] { ]; } +function nativeDefinition(operation: OntologyChangeOperation) { + return operation.concept ?? operation.property ?? operation.axiom; +} + +function scopeName(changeSet: OntologyChangeSet): string { + const scope = changeSet.ontology + ? [changeSet.ontology] + : changeSet.glossaries ?? []; + + return scope.map(getEntityName).join(', '); +} + function operationName(operation: OntologyChangeOperation): string { + const definition = nativeDefinition(operation); + if (definition) { + return getEntityName(definition); + } if (operation.operationType === OperationType.CreateGlossary) { return operation.glossary?.displayName || operation.glossary?.name || ''; } - return operation.term?.displayName || operation.term?.name || ''; + return ( + operation.term?.displayName || + operation.term?.name || + operation.targetId || + '' + ); } function changeSetName(changeSet: OntologyChangeSet): string { @@ -132,7 +181,8 @@ function reviewableDrafts(drafts: OntologyChangeSet[]): OntologyChangeSet[] { // The deep-linked draft is fetched alongside the first page so it remains reachable. async function loadMemoryDrafts( initialDraftId?: string, - after?: string + after?: string, + ontologyId?: string ): Promise<{ drafts: OntologyChangeSet[]; after?: string }> { const [linked, page] = await Promise.all([ initialDraftId && !after @@ -151,7 +201,10 @@ async function loadMemoryDrafts( return { after: page.paging.after, - drafts: reviewableDrafts([...(linked ? [linked] : []), ...page.data]), + drafts: reviewableDrafts([ + ...(linked ? [linked] : []), + ...page.data, + ]).filter((draft) => !ontologyId || draft.ontology?.id === ontologyId), }; } @@ -182,9 +235,7 @@ function DraftList({ {changeSetName(changeSet)} - {changeSet.glossaries - .map((glossary) => glossary.displayName || glossary.name) - .join(', ')} + {scopeName(changeSet)} {' · '} {stateLabel(changeSet.state, t)} @@ -199,7 +250,9 @@ function DraftList({ function OperationCard({ operation }: { operation: OntologyChangeOperation }) { const { t } = useTranslation(); const description = - operation.term?.description || operation.glossary?.description; + nativeDefinition(operation)?.description || + operation.term?.description || + operation.glossary?.description; return (
  • @@ -207,13 +260,20 @@ function OperationCard({ operation }: { operation: OntologyChangeOperation }) { {operationName(operation)} - {operationTypeLabel(operation.operationType, t)} + {operationTypeLabel(operation, t)} {description ? ( {description} ) : null} + {nativeDefinition(operation) && ( +
    +          {JSON.stringify(nativeDefinition(operation), null, 2)}
    +        
    + )} {operation.rationale ? ( {operation.rationale} @@ -316,12 +376,10 @@ function DraftDetail({
    - {t('label.glossary')} + {t(changeSet.ontology ? 'label.ontology' : 'label.glossary')} - {changeSet.glossaries - .map((glossary) => glossary.displayName || glossary.name) - .join(', ')} + {scopeName(changeSet)}
    @@ -370,11 +428,30 @@ function DraftDetail({ ); } +function useDraftPermissions( + ontology: OntologyChangeSet['ontology'], + defaults: Pick< + OntologyMemoryReviewPanelProps, + 'canApply' | 'canSubmit' | 'canDiscard' + > +) { + const { canEditAll } = useEntityPermissions( + ResourceEntity.ONTOLOGY, + { id: ontology?.id ?? '' }, + { enabled: Boolean(ontology) } + ); + + return ontology + ? { canApply: canEditAll, canSubmit: canEditAll, canDiscard: canEditAll } + : defaults; +} + const OntologyMemoryReviewPanel = ({ canApply, canDiscard, canSubmit, initialDraftId, + ontologyId, onApplied, }: OntologyMemoryReviewPanelProps) => { const { t } = useTranslation(); @@ -387,8 +464,18 @@ const OntologyMemoryReviewPanel = ({ const [isSaving, setIsSaving] = useState(false); const [nextCursor, setNextCursor] = useState(); const selected = selectedDraft(changeSets, selectedId); + const permissions = useDraftPermissions(selected?.ontology, { + canApply, + canSubmit, + canDiscard, + }); const lease = useOntologyEditLease({ - isActive: Boolean(selected && (canSubmit || canApply || canDiscard)), + isActive: Boolean( + selected && + (permissions.canSubmit || + permissions.canApply || + permissions.canDiscard) + ), resourceId: selected?.id, resourceType: 'ontologyChangeSet', }); @@ -396,7 +483,11 @@ const OntologyMemoryReviewPanel = ({ const loadChangeSets = useCallback(async () => { setIsLoading(true); try { - const page = await loadMemoryDrafts(initialDraftId); + const page = await loadMemoryDrafts( + initialDraftId, + undefined, + ontologyId + ); setChangeSets(page.drafts); setNextCursor(page.after); setSelectedId((current) => @@ -409,7 +500,7 @@ const OntologyMemoryReviewPanel = ({ } finally { setIsLoading(false); } - }, [initialDraftId]); + }, [initialDraftId, ontologyId]); useEffect(() => { void loadChangeSets(); @@ -421,7 +512,7 @@ const OntologyMemoryReviewPanel = ({ } setIsLoadingMore(true); try { - const page = await loadMemoryDrafts(undefined, nextCursor); + const page = await loadMemoryDrafts(undefined, nextCursor, ontologyId); setChangeSets((current) => reviewableDrafts([...current, ...page.drafts]) ); @@ -538,9 +629,9 @@ const OntologyMemoryReviewPanel = ({ {selected ? ( ; + content = ; break; case 'iri': diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyModelingWorkbench.utils.test.ts b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyModelingWorkbench.utils.test.ts index 61b7e7cf2c52..cd8804d6404f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyModelingWorkbench.utils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyModelingWorkbench.utils.test.ts @@ -68,7 +68,7 @@ describe('OntologyModelingWorkbench utilities', () => { expect(request).toEqual( expect.objectContaining({ description: 'Classification', - glossary: 'governed', + ontology: 'governed', subjectIri: 'https://example.org/Customer', targetIri: 'https://example.org/Party', }) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyModelingWorkbench.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyModelingWorkbench.utils.ts index 6bca91e76307..17e93a4933af 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyModelingWorkbench.utils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyModelingWorkbench.utils.ts @@ -14,7 +14,6 @@ import { AxiomType, CreateOntologyAxiom, - EntityStatus, OntologyExpression, Provenance, } from '../../generated/api/data/createOntologyAxiom'; @@ -218,16 +217,15 @@ export const buildOntologyPatternRequest = ( }; export const buildOntologyAxiomRequest = ( - glossaryFqn: string, + ontologyFqn: string, form: OntologyAxiomFormState ): CreateOntologyAxiom => { const request: CreateOntologyAxiom = { axiomType: form.axiomType, description: form.description.trim(), displayName: form.displayName.trim(), - entityStatus: EntityStatus.Draft, expressions: form.expressions.length ? form.expressions : undefined, - glossary: glossaryFqn, + ontology: ontologyFqn, literal: literal(form), name: form.name.trim(), propertyIri: optional(form.propertyIri), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyStudioQueryConsole.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyStudioQueryConsole.test.tsx index a63afe8ac3a3..39308a9ff597 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyStudioQueryConsole.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyStudioQueryConsole.test.tsx @@ -22,6 +22,7 @@ import { replaceSavedSparqlQueries, replaceSparqlQueryTemplates, runGlossarySparqlQuery, + runOntologySparqlQuery, runSparqlQuery, SavedSparqlQuery, } from '../../rest/rdfAPI'; @@ -67,6 +68,7 @@ jest.mock('../../rest/rdfAPI', () => ({ replaceSavedSparqlQueries: jest.fn(), replaceSparqlQueryTemplates: jest.fn(), runGlossarySparqlQuery: jest.fn(), + runOntologySparqlQuery: jest.fn(), runSparqlQuery: jest.fn(), })); @@ -319,6 +321,38 @@ describe('OntologyStudioQueryConsole', () => { expect(mockRunGlossarySparqlQuery).not.toHaveBeenCalled(); }); + it('runs a native ontology query for a non-admin without requiring a glossary', async () => { + const run = runOntologySparqlQuery as jest.MockedFunction< + typeof runOntologySparqlQuery + >; + run.mockResolvedValue({ + format: 'json', + body: '{}', + contentType: 'application/sparql-results+json', + durationMs: 1, + }); + render( + + ); + fireEvent.click( + screen.getByTestId('ontology-query-suggestion-ontology-relatedTo-target') + ); + await waitFor(() => + expect(run).toHaveBeenCalledWith( + 'native', + expect.objectContaining({ format: 'json' }) + ) + ); + + expect(mockRunSparqlQuery).not.toHaveBeenCalled(); + expect(mockRunGlossarySparqlQuery).not.toHaveBeenCalled(); + }); + it('toggles triple-shaped query results from a table to a subgraph', async () => { mockRunGlossarySparqlQuery.mockResolvedValueOnce({ body: '', diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyStudioQueryConsole.tsx b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyStudioQueryConsole.tsx index 1f9670c7b1b3..c550e0d481ff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyStudioQueryConsole.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyStudioQueryConsole.tsx @@ -49,6 +49,7 @@ import { useAuth } from '../../hooks/authHooks'; import { useSparqlQueryLibrary } from '../../hooks/useSparqlQueryLibrary'; import { runGlossarySparqlQuery, + runOntologySparqlQuery, runSparqlQuery, SavedSparqlQuery, SparqlPlaygroundResult, @@ -73,6 +74,7 @@ interface OntologyStudioQueryConsoleProps { readonly graphData: OntologyGraphData | null; readonly relationTypes: RelationshipType[]; readonly selectedGlossaryIds: string[]; + readonly selectedOntologyId?: string; } type SaveTarget = 'personal' | 'template'; @@ -600,6 +602,7 @@ const OntologyStudioQueryConsole = ({ graphData, relationTypes, selectedGlossaryIds, + selectedOntologyId, }: OntologyStudioQueryConsoleProps) => { const { t } = useTranslation(); const { isAdminUser } = useAuth(); @@ -695,14 +698,22 @@ const OntologyStudioQueryConsole = ({ inference: 'none' as const, }; const glossaryId = selectedGlossaryIds[0]; - if (!glossaryId && !isAdminUser) { + if (!glossaryId && !selectedOntologyId && !isAdminUser) { throw new Error( t('label.please-select-entity', { entity: t('label.glossary') }) ); } - const nextResult = glossaryId - ? await runGlossarySparqlQuery(glossaryId, queryParams) - : await runSparqlQuery(queryParams); + let nextResult; + if (selectedOntologyId) { + nextResult = await runOntologySparqlQuery( + selectedOntologyId, + queryParams + ); + } else if (glossaryId) { + nextResult = await runGlossarySparqlQuery(glossaryId, queryParams); + } else { + nextResult = await runSparqlQuery(queryParams); + } setResult(nextResult); } catch (error) { let message: string; @@ -720,7 +731,7 @@ const OntologyStudioQueryConsole = ({ setRunning(false); } }, - [isAdminUser, selectedGlossaryIds, t] + [isAdminUser, selectedGlossaryIds, selectedOntologyId, t] ); const runListedQuery = useCallback( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/utils/graphBuilders.ts b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/utils/graphBuilders.ts index de4b77799c30..f83f0bb170d9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/utils/graphBuilders.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/utils/graphBuilders.ts @@ -56,7 +56,11 @@ export function isValidUUID(str: string): boolean { } export function isTermNode(node: OntologyNode): boolean { - return node.type === 'glossaryTerm' || node.type === 'glossaryTermIsolated'; + return ( + node.type === 'glossaryTerm' || + node.type === 'glossaryTermIsolated' || + node.type === 'ontologyConcept' + ); } export function isDataAssetLikeNode(node: OntologyNode): boolean { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteEntityModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteEntityModal.test.tsx index eaf8dd1777e6..f51c62a8a6ba 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteEntityModal.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteEntityModal.test.tsx @@ -101,6 +101,32 @@ describe('DeleteEntityModal', () => { expect(deleteEntity).toHaveBeenCalled(); }); + it.each([false, true])( + 'deletes ontology properties through their collection endpoint (permanent: %s)', + async (permanent) => { + render( + + ); + + if (permanent) { + fireEvent.click(await screen.findByTestId('hard-delete')); + } + await act(async () => { + fireEvent.click(screen.getByTestId('confirm-button')); + }); + + expect(deleteEntity).toHaveBeenCalledWith( + 'ontologyProperties', + mockProps.entityId, + false, + permanent + ); + } + ); + it('should call onCancel when discard is clicked', async () => { render(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.test.tsx index 87c50b62e476..878854e5c562 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.test.tsx @@ -13,6 +13,14 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { ComponentProps, ComponentType, ReactNode } from 'react'; +import APIClient from '../../../../../rest/axiosClient'; +import { + renderWithQueryClient, + runQueryNotificationsSynchronously, +} from '../../../../../test/unit/test-utils'; + +jest.mock('../../../../../rest/axiosClient', () => ({ get: jest.fn() })); +runQueryNotificationsSynchronously(); const mockGetTaskById = jest.fn(); const mockResolveTask = jest.fn(); @@ -296,6 +304,7 @@ jest.mock('components/common/RichTextEditor/RichTextEditorPreviewerV1', () => ({ })); jest.mock('@openmetadata/ui-core-components', () => ({ + Alert: jest.requireActual('@openmetadata/ui-core-components').Alert, Dot: () => , BadgeWithDot: ({ children, @@ -702,6 +711,54 @@ describe('TaskDetailPanel', () => { ); }); + it('keeps an ontology review open when the workflow still needs another vote', async () => { + const onResolved = jest.fn(); + const task = { + ...TASK, + type: 'RequestApproval', + category: 'Approval', + about: { id: 'release-id', type: 'ontologyRelease' }, + ontologyReview: { + entityId: 'release-id', + entityVersion: 0.2, + ontologyId: 'ontology-id', + author: 'author', + contentDigest: 'a'.repeat(64), + }, + availableTransitions: [ + { + id: 'approve', + label: 'Approve', + resolutionType: 'Approved', + targetTaskStatus: 'Approved', + }, + ], + }; + (APIClient.get as jest.Mock).mockImplementation(async (path: string) => ({ + data: path.endsWith('/diff') + ? { data: [], paging: { total: 0 } } + : { id: 'release-id', version: 0.2, name: 'Release' }, + })); + mockGetTaskById.mockResolvedValue({ data: task }); + mockResolveTask.mockResolvedValue({ ...task, status: 'Open' }); + + await act(async () => + renderWithQueryClient( + + ) + ); + await act(async () => { + fireEvent.click(await screen.findByTestId('task-approve')); + }); + + expect(onResolved).toHaveBeenCalledWith( + expect.objectContaining({ status: 'Open' }) + ); + expect( + screen.queryByText('message.task-outcome-by-on') + ).not.toBeInTheDocument(); + }); + it('runs a non-approve/reject transition (e.g. Revoke) with its id and type', async () => { const onResolved = jest.fn(); mockGetTaskById.mockResolvedValue({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx index 48b6dc9e058c..4a1570c1e3b7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx @@ -55,7 +55,9 @@ import { import { getPermissionErrorText } from '../../../../../utils/StringUtils'; import { getResolvedTaskFormSchema } from '../../../../../utils/TaskFormSchemaUtils'; import { getTaskAboutPath } from '../../../../../utils/TaskNavigationUtils'; +import { isOntologyReviewTask } from '../../../../../utils/TaskUtils'; import { showErrorToast } from '../../../../../utils/ToastUtils'; +import OntologyReviewPanel from '../../../../governance/ontology/OntologyReviewPanel/OntologyReviewPanel'; import { useApplicationsProvider } from '../../../../Settings/Applications/ApplicationsProvider/ApplicationsProvider'; import { formatInboxDate, isTaskOpen } from '../inbox.utils'; import { @@ -285,6 +287,13 @@ const TaskDetailPanel: React.FC = ({ const { extensionRegistry } = useApplicationsProvider(); const [task, setTask] = useState(fallbackTask); const [isLoading, setIsLoading] = useState(true); + const [readyReviewTask, setReadyReviewTask] = useState(); + const handleReviewReady = useCallback( + (ready: boolean) => { + setReadyReviewTask(ready ? task?.id : undefined); + }, + [task?.id] + ); // The task id whose full record has loaded. The list row the panel opens with // lacks availableTransitions, so actions wait for it — but only the first // time: a reload after a comment keeps the buttons and status steady. @@ -432,8 +441,13 @@ const TaskDetailPanel: React.FC = ({ ); } - return effective; + return isOntologyReviewTask(task) && readyReviewTask !== task.id + ? effective.filter( + (action) => action.kind !== 'approve' && action.kind !== 'reject' + ) + : effective; }, [ + readyReviewTask, task, hasFullTask, isSyncingTransitions, @@ -537,7 +551,10 @@ const TaskDetailPanel: React.FC = ({ // server response is authoritative there. const resolved = { ...updated, - status: action.targetTaskStatus ?? updated.status, + // Native review responses retain Open until the approval threshold is met. + status: isOntologyReviewTask(task) + ? updated.status + : action.targetTaskStatus ?? updated.status, }; consumedTransitionIdsRef.current.add(action.id); setTask(resolved); @@ -663,6 +680,33 @@ const TaskDetailPanel: React.FC = ({ ) : null; const LegacyPanel = contribution?.component; + const renderDetails = () => { + if (isOntologyReviewTask(task)) { + return ( + + ); + } + + return ( + <> + + {LegacyPanel ? ( + + ) : ( + + )} + + ); + }; return ( = ({ direction="col" gap={6} onScroll={onBodyScroll}> - - - {LegacyPanel ? ( - - ) : ( - - )} + {renderDetails()} { }); describe('getTaskResolveActions', () => { + it('waits for workflow transitions for a release review', () => { + expect( + getTaskResolveActions( + makeTask({ + type: TaskType.RequestApproval, + category: TaskCategory.Approval, + about: { id: 'release-id', type: 'ontologyRelease' }, + availableTransitions: [], + }), + LABELS + ) + ).toEqual([]); + }); + // /close and the expiry timer leave the workflow transitions on the task; a // task closed that way must not keep offering them. it.each([ @@ -290,6 +304,27 @@ describe('buildResolveBody', () => { const firstAction = (task: Task, schema?: TaskFormSchema) => getTaskResolveActions(task, LABELS, schema); + it('sends an ontology review decision without an editable entity payload', () => { + const task = makeTask({ + type: TaskType.RequestApproval, + category: TaskCategory.Approval, + about: { id: 'concept-id', type: 'ontologyRelease' }, + availableTransitions: [DAR_REJECT], + payload: { proposedChanges: { description: 'captured definition' } }, + }); + + expect( + buildResolveBody(firstAction(task)[0], task, { + comment: 'The definition needs a narrower scope', + payload: { proposedChanges: { description: 'edited during review' } }, + }) + ).toStrictEqual({ + transitionId: 'reject', + resolutionType: 'Rejected', + comment: 'The definition needs a narrower scope', + }); + }); + it('names the transition of a workflow action and omits a resolutionType the transition does not declare', () => { const task = makeTask({ availableTransitions: [DAR_APPROVE], diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/taskResolve.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/taskResolve.utils.ts index 94cb715a3611..0fd87de7f166 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/taskResolve.utils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/taskResolve.utils.ts @@ -30,6 +30,10 @@ import { getTaskResolutionNewValue, shouldRequireTaskResolutionValue, } from '../../../../utils/TaskFormSchemaUtils'; +import { + getTaskResolveBody, + isOntologyReviewTask, +} from '../../../../utils/TaskUtils'; import { isTaskOpen } from './inbox.utils'; import { TaskActionLabelOverrides } from './taskDetail.types'; import { isApproveTransition, isRejectTransition } from './taskList.utils'; @@ -204,7 +208,9 @@ export const getTaskResolveActions = ( })); } - return getLegacyActions(task, labels, schema); + return isOntologyReviewTask(task) + ? [] + : getLegacyActions(task, labels, schema); }; /** @@ -269,12 +275,12 @@ export const buildResolveBody = ( const comment = extras?.comment ? { comment: extras.comment } : {}; if (action.transition) { - return { + return getTaskResolveBody(task, { transitionId: action.transition.id, resolutionType: action.transition.resolutionType, ...comment, ...(extras?.payload ? { payload: extras.payload } : {}), - }; + }); } const legacy = buildLegacyResolution( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/useTaskAboutEntity.ts b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/useTaskAboutEntity.ts index b56c328abc89..cfec8d64e197 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/useTaskAboutEntity.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/useTaskAboutEntity.ts @@ -22,6 +22,7 @@ import { getTableDetailsByFQN } from '../../../../rest/tableAPI'; import { getTestCaseByFqn } from '../../../../rest/testAPI'; import { getEntityByFqnUtil } from '../../../../utils/EntityByFqnUtils'; import EntityLink from '../../../../utils/EntityLink'; +import { isOntologyReviewTask } from '../../../../utils/TaskUtils'; import { EntityUnion } from '../../../Explore/ExplorePage.interface'; import { TaskAboutEntity } from './taskDetail.types'; import { @@ -48,6 +49,10 @@ interface TaskAboutTarget { // An incident often names no `about`; its failing test case is then read off the // description, which is still enough to show what failed and against what. const getAboutTarget = (task?: Task): TaskAboutTarget | undefined => { + if (task && isOntologyReviewTask(task)) { + return undefined; + } + const fqn = task?.about?.fullyQualifiedName; const entityType = task?.about?.type; if (fqn && entityType) { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAuthoringWorkspace/OntologyAuthoringWorkspace.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAuthoringWorkspace/OntologyAuthoringWorkspace.test.tsx new file mode 100644 index 000000000000..abc7645e11f7 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAuthoringWorkspace/OntologyAuthoringWorkspace.test.tsx @@ -0,0 +1,299 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { OntologyConceptType } from '../../../../generated/entity/data/ontologyConcept'; +import { Kind } from '../../../../generated/entity/data/ontologyProperty'; +import { Access } from '../../../../generated/entity/policies/accessControl/resourcePermission'; +import APIClient from '../../../../rest/axiosClient'; +import { + flushReactQuery, + renderWithQueryClient, + runQueryNotificationsSynchronously, +} from '../../../../test/unit/test-utils'; +import OntologyAuthoringWorkspace from './OntologyAuthoringWorkspace'; + +let mockPermissions = { + ontology: { Create: true }, + ontologyConcept: { Create: true }, + ontologyProperty: { Create: true }, +}; + +jest.mock('../../../../rest/axiosClient', () => ({ + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), +})); +jest.mock('../../../../context/PermissionProvider/PermissionProvider', () => ({ + usePermissionProvider: () => ({ permissions: mockPermissions }), +})); +jest.mock('../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); +const get = APIClient.get as jest.MockedFunction; +const post = APIClient.post as jest.MockedFunction; + +const ontology: Ontology = { + id: 'ontology-id', + name: 'Sales', + fullyQualifiedName: 'Sales', + iri: 'urn:sales', + namespace: 'urn:sales:', +}; +const concept = { + id: 'concept-id', + name: 'Customer', + fullyQualifiedName: 'Sales.Customer', + ontology: { id: ontology.id, type: 'ontology' }, + conceptType: OntologyConceptType.OwlClass, + iri: 'urn:sales:customer', + version: 0.1, +}; + +runQueryNotificationsSynchronously(); + +beforeAll(() => jest.useRealTimers()); + +afterAll(() => jest.useFakeTimers()); + +describe('OntologyAuthoringWorkspace', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPermissions = { + ontology: { Create: true }, + ontologyConcept: { Create: true }, + ontologyProperty: { Create: true }, + }; + get.mockImplementation(async (path) => { + if (path === '/ontologies') { + return { data: { data: [ontology], paging: { total: 1 } } }; + } + if (path === '/ontologyConcepts') { + return { data: { data: [concept], paging: { total: 1 } } }; + } + if (path === '/ontologyProperties') { + return { data: { data: [], paging: { total: 0 } } }; + } + if (path?.startsWith('/permissions/')) { + return { + data: { + permissions: [ + { operation: 'ViewAll', access: Access.Allow }, + { operation: 'EditAll', access: Access.Allow }, + ], + }, + }; + } + + throw new Error('Unexpected request: ' + path); + }); + }); + + it('browses and authors native concepts without a glossary request', async () => { + renderWithQueryClient(); + await flushReactQuery(); + + expect( + await screen.findByRole('button', { name: 'Customer' }) + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Customer' })); + }); + + expect( + await screen.findByRole('textbox', { name: 'label.concept-iri' }) + ).toHaveValue(concept.iri); + expect(screen.getByRole('button', { name: 'label.save' })).toBeEnabled(); + expect(get).toHaveBeenCalledWith( + '/ontologyConcepts', + expect.objectContaining({ + params: expect.objectContaining({ ontology: 'Sales', limit: 25 }), + }) + ); + expect(get.mock.calls.some(([path]) => path?.includes('glossar'))).toBe( + false + ); + }); + + it('creates in the selected ontology and shows the saved identity', async () => { + post.mockResolvedValue({ + data: { ...concept, name: 'Account', iri: 'urn:sales:account' }, + }); + renderWithQueryClient(); + await flushReactQuery(); + fireEvent.click(screen.getByTestId('ontology-add-concept')); + fireEvent.change(screen.getByRole('textbox', { name: 'label.name' }), { + target: { value: 'Account' }, + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + }); + await waitFor(() => + expect(post).toHaveBeenCalledWith( + '/ontologyConcepts', + expect.objectContaining({ + ontology: 'Sales', + name: 'Account', + conceptType: OntologyConceptType.OwlClass, + }) + ) + ); + + expect( + await screen.findByRole('textbox', { name: 'label.concept-iri' }) + ).toHaveValue('urn:sales:account'); + }); + + it('creates native properties using the selected ontology and property permissions', async () => { + post.mockResolvedValue({ + data: { + ...concept, + id: 'property-id', + name: 'purchasedBy', + kind: Kind.Object, + }, + }); + renderWithQueryClient( + + ); + await flushReactQuery(); + fireEvent.click(screen.getByTestId('ontology-add-property')); + fireEvent.change(screen.getByRole('textbox', { name: 'label.name' }), { + target: { value: 'purchasedBy' }, + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + }); + + await waitFor(() => + expect(post).toHaveBeenCalledWith( + '/ontologyProperties', + expect.objectContaining({ + ontology: 'Sales', + name: 'purchasedBy', + kind: Kind.Object, + }) + ) + ); + + expect( + await screen.findByRole('textbox', { name: 'label.property-iri' }) + ).toHaveValue(concept.iri); + expect(get).toHaveBeenCalledWith( + '/permissions/ontologyProperty/property-id' + ); + }); + + it('shows a failed list with retry and hides unavailable data', async () => { + get.mockImplementation(async (path) => { + if (path === '/ontologies') { + return { data: { data: [ontology], paging: { total: 1 } } }; + } + + throw new Error('Unavailable'); + }); + renderWithQueryClient(); + await flushReactQuery(); + + expect(await screen.findByRole('alert')).toBeInTheDocument(); + + get.mockResolvedValue({ data: { data: [concept], paging: { total: 1 } } }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'label.retry' })); + }); + + expect( + await screen.findByRole('button', { name: 'Customer' }) + ).toBeInTheDocument(); + }); + + it('does not show a selected definition when its read permission is denied', async () => { + get + .mockResolvedValueOnce({ + data: { data: [concept], paging: { total: 1 } }, + }) + .mockResolvedValueOnce({ + data: { permissions: [{ operation: 'ViewAll', access: Access.Deny }] }, + }); + renderWithQueryClient(); + await flushReactQuery(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Customer' })); + }); + + expect(await screen.findByRole('alert')).toBeInTheDocument(); + expect( + screen.queryByTestId('ontology-concept-editor') + ).not.toBeInTheDocument(); + }); + + it('uses the server cursor to browse the next page', async () => { + get + .mockResolvedValueOnce({ + data: { data: [concept], paging: { total: 2, after: 'next-cursor' } }, + }) + .mockResolvedValueOnce({ + data: { + data: [{ ...concept, id: 'account', name: 'Account' }], + paging: { total: 2, before: 'previous-cursor' }, + }, + }); + renderWithQueryClient(); + await flushReactQuery(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'label.next' })); + }); + + expect( + await screen.findByRole('button', { name: 'Account' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Customer' }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'label.previous' }) + ).toBeEnabled(); + expect(get).toHaveBeenLastCalledWith( + '/ontologyConcepts', + expect.objectContaining({ + params: expect.objectContaining({ + after: 'next-cursor', + ontology: 'Sales', + }), + }) + ); + }); + + it('keeps create actions hidden for a reader', async () => { + mockPermissions = { + ontology: { Create: false }, + ontologyConcept: { Create: false }, + ontologyProperty: { Create: false }, + }; + renderWithQueryClient(); + + expect( + screen.queryByTestId('ontology-add-ontology') + ).not.toBeInTheDocument(); + + await flushReactQuery(); + + expect( + screen.queryByTestId('ontology-add-concept') + ).not.toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAuthoringWorkspace/OntologyAuthoringWorkspace.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAuthoringWorkspace/OntologyAuthoringWorkspace.tsx new file mode 100644 index 000000000000..2a5f289e7080 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAuthoringWorkspace/OntologyAuthoringWorkspace.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { usePermissionProvider } from '../../../../context/PermissionProvider/PermissionProvider'; +import { ResourceEntity } from '../../../../enums/permissions.enum'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { + listOntologyConcepts, + listOntologyProperties, +} from '../../../../rest/ontologyAPI'; +import { getDerivedPermissionFlags } from '../../../../utils/PermissionDerivation'; +import { DEFAULT_ENTITY_PERMISSION } from '../../../../utils/PermissionsUtils'; +import OntologyConceptEditor from '../OntologyConceptEditor/OntologyConceptEditor'; +import OntologyPropertyEditor from '../OntologyPropertyEditor/OntologyPropertyEditor'; +import OntologyDefinitionList from './OntologyDefinitionList'; + +interface OntologyAuthoringWorkspaceProps { + ontology: Ontology; + definitionType?: 'concept' | 'property'; +} + +const OntologyAuthoringWorkspace = ({ + ontology, + definitionType = 'concept', +}: OntologyAuthoringWorkspaceProps) => { + const { permissions } = usePermissionProvider(); + const { canCreate: canCreateDefinition } = getDerivedPermissionFlags( + permissions[ + definitionType === 'concept' + ? ResourceEntity.ONTOLOGY_CONCEPT + : ResourceEntity.ONTOLOGY_PROPERTY + ] ?? DEFAULT_ENTITY_PERMISSION, + ontology?.deleted + ); + + if (definitionType === 'concept') { + return ( + + {(concept, canEdit, onSaved) => ( + + )} + + ); + } + + return ( + + {(property, canEdit, onSaved) => ( + + )} + + ); +}; + +export default OntologyAuthoringWorkspace; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAuthoringWorkspace/OntologyDefinitionList.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAuthoringWorkspace/OntologyDefinitionList.tsx new file mode 100644 index 000000000000..8eada6154f6a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAuthoringWorkspace/OntologyDefinitionList.tsx @@ -0,0 +1,232 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Alert, + Box, + Button, + Typography, +} from '@openmetadata/ui-core-components'; +import { useQuery } from '@tanstack/react-query'; +import { PagingResponse } from 'Models'; +import { Fragment, ReactNode, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { EntityType } from '../../../../enums/entity.enum'; +import { ResourceEntity } from '../../../../enums/permissions.enum'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { OntologyConcept } from '../../../../generated/entity/data/ontologyConcept'; +import { useEntityPermissions } from '../../../../hooks/useEntityPermissions/useEntityPermissions'; +import { ListParams } from '../../../../interface/API.interface'; +import { getEntityName } from '../../../../utils/EntityNameUtils'; +import DeleteEntityModal from '../../../common/DeleteWidget/DeleteEntityModal'; + +interface OntologyDefinitionListProps { + ontology: Ontology; + canCreate: boolean; + definitionType: 'concept' | 'property' | 'axiom'; + load: ( + params: ListParams & { ontology?: string }, + signal?: AbortSignal + ) => Promise>; + children: ( + definition: T | undefined, + canEdit: boolean, + onSaved: (saved: T) => void + ) => ReactNode; +} + +const DEFINITION_RESOURCES = { + concept: ResourceEntity.ONTOLOGY_CONCEPT, + property: ResourceEntity.ONTOLOGY_PROPERTY, + axiom: ResourceEntity.ONTOLOGY_AXIOM, +}; +const DEFINITION_ENTITIES = { + concept: EntityType.ONTOLOGY_CONCEPT, + property: EntityType.ONTOLOGY_PROPERTY, + axiom: EntityType.ONTOLOGY_AXIOM, +}; + +const OntologyDefinitionList = < + T extends Pick< + OntologyConcept, + 'id' | 'name' | 'displayName' | 'version' | 'deleted' + > +>({ + ontology, + canCreate, + definitionType, + load, + children, +}: OntologyDefinitionListProps) => { + const { t } = useTranslation(); + const label = definitionType === 'axiom' ? 'constraint' : definitionType; + const entityLabel = t(`label.${label}`); + const pluralLabel = t(`label.${label}-plural`); + const [pagination, setPagination] = useState< + Pick + >({}); + const [selected, setSelected] = useState(); + const [isDeleting, setIsDeleting] = useState(false); + const definition = selected === 'new' ? undefined : selected; + const permission = useEntityPermissions( + DEFINITION_RESOURCES[definitionType], + { id: definition?.id ?? '' }, + { + enabled: Boolean(definition), + deleted: definition?.deleted, + } + ); + const page = useQuery({ + queryKey: ['ontology-definitions', definitionType, ontology.id, pagination], + queryFn: ({ signal }) => + load( + { + ontology: ontology.fullyQualifiedName, + limit: 25, + fields: 'owners', + ...pagination, + }, + signal + ), + retry: false, + gcTime: 0, + }); + + if (page.isError) { + return ( + + + {t('server.entity-fetch-error', { + entity: pluralLabel, + })} + + + + ); + } + + const renderDelete = () => ( + <> + {definition && permission.canDelete && ( + + )} + {definition && isDeleting && ( + { + setIsDeleting(false); + setSelected(undefined); + void page.refetch(); + }} + entityId={definition.id} + entityName={getEntityName(definition)} + entityType={DEFINITION_ENTITIES[definitionType]} + onCancel={() => setIsDeleting(false)} + /> + )} + + ); + + const renderEditor = () => { + if (!selected) { + return null; + } + if (definition && permission.isLoading) { + return {t('label.loading')}; + } + if (definition && !permission.hasViewAccess) { + return ( + + {t('server.entity-fetch-error', { entity: entityLabel })} + + ); + } + + return ( + + {children( + definition, + selected === 'new' ? canCreate : permission.canEditAll, + (saved) => { + setSelected(saved); + void page.refetch(); + } + )} + {renderDelete()} + + ); + }; + + return ( + + + + {pluralLabel} + + {canCreate && ( + + )} + + {page.isPending ? ( + {t('label.loading')} + ) : ( +
      + {page.data.data.map((item) => ( +
    • + +
    • + ))} +
    + )} + + + + + {renderEditor()} +
    + ); +}; + +export default OntologyDefinitionList; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomEditor/OntologyAxiomEditor.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomEditor/OntologyAxiomEditor.test.tsx new file mode 100644 index 000000000000..5b41bbd5904f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomEditor/OntologyAxiomEditor.test.tsx @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + AxiomType, + ExpressionKind, + Provenance, +} from '../../../../generated/api/data/createOntologyAxiom'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { + createOntologyAxiom, + validateOntologyAxiom, +} from '../../../../rest/ontologyAPI'; +import OntologyAxiomEditor from './OntologyAxiomEditor'; + +jest.mock('../../../../rest/ontologyAPI', () => ({ + createOntologyAxiom: jest.fn(), + validateOntologyAxiom: jest.fn(), +})); + +const mockCreate = createOntologyAxiom as jest.MockedFunction< + typeof createOntologyAxiom +>; +const mockValidate = validateOntologyAxiom as jest.MockedFunction< + typeof validateOntologyAxiom +>; +const ONTOLOGY: Ontology = { + description: 'Governed axiom', + displayName: 'Governed glossary', + fullyQualifiedName: 'GovernedGlossary', + id: 'glossary-id', + name: 'GovernedGlossary', + iri: 'urn:governed', + namespace: 'urn:governed:', +}; + +describe('OntologyAxiomEditor', () => { + it('creates only after the live OWL profile guard accepts the typed axiom', async () => { + mockValidate.mockResolvedValue({ valid: true, violations: [] }); + mockCreate.mockResolvedValue({ + axiomType: AxiomType.SubclassOf, + description: ONTOLOGY.description ?? '', + displayName: ONTOLOGY.displayName ?? ONTOLOGY.name, + expressions: [ + { + classIri: 'https://example.test/Class', + kind: ExpressionKind.NamedClass, + }, + ], + fullyQualifiedName: 'GovernedGlossary.axiom-GovernedGlossary', + ontology: { id: ONTOLOGY.id, type: 'ontology' }, + id: 'axiom-id', + name: 'axiom-GovernedGlossary', + provenance: Provenance.Manual, + subjectIri: 'https://example.test/Subject', + }); + render(); + + fireEvent.change( + screen.getByRole('textbox', { name: 'label.display-name' }), + { target: { value: 'Subclass' } } + ); + fireEvent.change( + screen.getByRole('textbox', { name: 'label.description' }), + { target: { value: 'Customer is a party' } } + ); + fireEvent.change(screen.getByRole('textbox', { name: 'label.subject' }), { + target: { value: 'https://example.test/Subject' }, + }); + fireEvent.change( + screen.getByRole('textbox', { name: /label\.concept-iri/ }), + { target: { value: 'https://example.test/Class' } } + ); + fireEvent.click(screen.getByTestId('ontology-axiom-validate')); + + await screen.findByText('label.validation-passed'); + fireEvent.click(screen.getByTestId('ontology-axiom-submit')); + + await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1)); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + axiomType: AxiomType.SubclassOf, + expressions: [ + { + classIri: 'https://example.test/Class', + kind: ExpressionKind.NamedClass, + }, + ], + ontology: ONTOLOGY.fullyQualifiedName, + provenance: Provenance.Manual, + subjectIri: 'https://example.test/Subject', + }) + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomEditor/OntologyAxiomEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomEditor/OntologyAxiomEditor.tsx new file mode 100644 index 000000000000..c45ace08a80b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomEditor/OntologyAxiomEditor.tsx @@ -0,0 +1,472 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Alert, + Button, + Card, + FieldProp, + FieldTypes, + getField, + HookForm, + Input, + Select, + Typography, +} from '@openmetadata/ui-core-components'; +import { compare } from 'fast-json-patch'; +import { isUndefined, omitBy, pick } from 'lodash'; +import { Key, useEffect, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { + AxiomType, + ExpressionKind, +} from '../../../../generated/api/data/createOntologyAxiom'; +import { OntologyProfileReport } from '../../../../generated/api/data/ontologyProfileReport'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { OntologyAxiom } from '../../../../generated/entity/data/ontologyAxiom'; +import { + createOntologyAxiom, + patchOntologyAxiom, + validateOntologyAxiom, +} from '../../../../rest/ontologyAPI'; +import { generateUUID } from '../../../../utils/StringUtils'; +import { showErrorToast, showSuccessToast } from '../../../../utils/ToastUtils'; +import OntologyExpressionEditor, { + defaultExpression, + getExpressionRenderKey, +} from '../../../OntologyExplorer/OntologyExpressionEditor'; +import { + buildOntologyAxiomRequest, + EMPTY_AXIOM_FORM, + isOntologyAxiomReady, + OntologyAxiomFormState, +} from '../../../OntologyExplorer/OntologyModelingWorkbench.utils'; + +interface OntologyAxiomEditorProps { + ontology: Ontology; + axiom?: OntologyAxiom; + canEdit: boolean; + onSaved?: (axiom: OntologyAxiom) => void; +} + +const LIVE_VALIDATION_DELAY_MILLIS = 450; + +const isClassExpressionAxiom = (axiomType: AxiomType) => + [ + AxiomType.ClassAssertion, + AxiomType.DisjointWith, + AxiomType.EquivalentClass, + AxiomType.SubclassOf, + ].includes(axiomType); + +const resetShape = ( + form: ReturnType>, + axiomType: AxiomType +) => { + form.setValue( + 'expressions', + isClassExpressionAxiom(axiomType) + ? [defaultExpression(ExpressionKind.NamedClass)] + : [] + ); + form.setValue('literalDatatypeIri', ''); + form.setValue('literalValue', ''); + form.setValue('propertyIri', ''); + form.setValue('targetIri', ''); +}; + +const axiomTypeLabel = ( + axiomType: AxiomType, + t: ReturnType['t'] +) => { + let label: string; + + switch (axiomType) { + case AxiomType.ClassAssertion: + label = t('label.classification'); + + break; + case AxiomType.DataPropertyAssertion: + label = t('label.custom-property'); + + break; + case AxiomType.DisjointWith: + label = t('label.conflict-resolution'); + + break; + case AxiomType.EquivalentClass: + label = t('label.exact-match'); + + break; + case AxiomType.ObjectPropertyAssertion: + label = t('label.relationship'); + + break; + case AxiomType.SubclassOf: + label = t('label.child-of'); + + break; + } + + return label; +}; + +const requiredField = ( + name: string, + label: string, + t: ReturnType['t'], + type: FieldTypes = FieldTypes.TEXT +): FieldProp => ({ + label, + name, + required: true, + rules: { required: t('label.field-required', { field: label }) }, + type, +}); + +const ProfileResult = ({ profile }: { profile: OntologyProfileReport }) => { + const { t } = useTranslation(); + + return ( + + {profile.violations.length ? ( +
      + {profile.violations.map((violation) => ( +
    • + {violation.message} +
    • + ))} +
    + ) : null} +
    + ); +}; + +const axiomFormValues = (axiom?: OntologyAxiom): OntologyAxiomFormState => { + if (!axiom) { + return { + ...EMPTY_AXIOM_FORM, + name: `axiom-${generateUUID()}`, + expressions: [defaultExpression(ExpressionKind.NamedClass)], + }; + } + + return { + ...EMPTY_AXIOM_FORM, + ...pick(axiom, [ + 'description', + 'displayName', + 'name', + 'axiomType', + 'expressions', + 'subjectIri', + 'targetIri', + 'propertyIri', + ]), + literalValue: axiom.literal?.value ?? '', + literalDatatypeIri: axiom.literal?.datatypeIri ?? '', + }; +}; + +const OntologyAxiomEditor = ({ + ontology, + axiom, + canEdit, + onSaved, +}: OntologyAxiomEditorProps) => { + const { t } = useTranslation(); + const form = useForm({ + defaultValues: { + ...EMPTY_AXIOM_FORM, + expressions: [defaultExpression(ExpressionKind.NamedClass)], + }, + }); + const values = form.watch(); + const validationSequence = useRef(0); + const [profile, setProfile] = useState(); + const [created, setCreated] = useState(); + const [isValidating, setIsValidating] = useState(false); + const ontologyFullyQualifiedName = + ontology.fullyQualifiedName ?? ontology.name; + const validationKey = JSON.stringify(values); + const validationValues = useRef(values); + validationValues.current = values; + + useEffect(() => { + form.reset(axiomFormValues(axiom)); + setCreated(undefined); + setProfile(undefined); + }, [ + form, + axiom, + ontology.description, + ontology.displayName, + ontology.id, + ontology.name, + ]); + + useEffect(() => { + if (!isOntologyAxiomReady(validationValues.current)) { + setProfile(undefined); + + return; + } + const sequence = ++validationSequence.current; + const timeout = window.setTimeout(() => { + validateOntologyAxiom( + buildOntologyAxiomRequest( + ontologyFullyQualifiedName, + validationValues.current + ) + ) + .then((report) => { + if (sequence === validationSequence.current) { + setProfile(report); + } + }) + .catch(() => { + if (sequence === validationSequence.current) { + setProfile(undefined); + } + }); + }, LIVE_VALIDATION_DELAY_MILLIS); + + return () => window.clearTimeout(timeout); + }, [ontologyFullyQualifiedName, validationKey]); + + const validate = async (submitted = values) => { + setIsValidating(true); + let report: OntologyProfileReport | undefined; + try { + report = await validateOntologyAxiom( + buildOntologyAxiomRequest(ontologyFullyQualifiedName, submitted) + ); + setProfile(report); + } catch { + showErrorToast(t('server.unexpected-error')); + } finally { + setIsValidating(false); + } + + return report; + }; + + const submit = async (submitted: OntologyAxiomFormState) => { + if (!canEdit) { + return; + } + const report = await validate(submitted); + if (report?.valid) { + try { + const request = buildOntologyAxiomRequest( + ontologyFullyQualifiedName, + submitted + ); + const fields = Object.keys(request).filter( + (field) => !['ontology', 'name', 'provenance'].includes(field) + ); + const saved = axiom + ? await patchOntologyAxiom(axiom.id, [ + { op: 'test', path: '/version', value: axiom.version }, + ...compare( + omitBy(pick(axiom, fields), isUndefined), + omitBy(pick(request, fields), isUndefined) + ), + ]) + : await createOntologyAxiom(request); + setCreated(saved); + onSaved?.(saved); + showSuccessToast(t('label.saved')); + } catch { + showErrorToast(t('server.unexpected-error')); + } + } + }; + + const selectAxiomType = (key: Key | null) => { + if (key) { + const axiomType = String(key) as AxiomType; + form.setValue('axiomType', axiomType, { shouldDirty: true }); + resetShape(form, axiomType); + setCreated(undefined); + } + }; + + const metadataFields: FieldProp[] = [ + { + ...requiredField('name', t('label.name'), t), + props: { isDisabled: Boolean(axiom) }, + }, + requiredField('displayName', t('label.display-name'), t), + requiredField( + 'description', + t('label.description'), + t, + FieldTypes.TEXTAREA + ), + requiredField('subjectIri', t('label.subject'), t), + ]; + const usesExpressions = isClassExpressionAxiom(values.axiomType); + const usesProperty = [ + AxiomType.DataPropertyAssertion, + AxiomType.ObjectPropertyAssertion, + ].includes(values.axiomType); + + return ( + + + + {t('label.ontology')} {t('label.constraint-plural')} + + +
    + {metadataFields.slice(0, 2).map((field) => ( +
    {getField(field)}
    + ))} +
    {getField(metadataFields[2])}
    +
    {getField(metadataFields[3])}
    +
    + + {usesProperty ? ( + + form.setValue('propertyIri', propertyIri) + } + /> + ) : null} + {values.axiomType === AxiomType.ObjectPropertyAssertion ? ( + form.setValue('targetIri', targetIri)} + /> + ) : null} + {values.axiomType === AxiomType.DataPropertyAssertion ? ( +
    + + form.setValue('literalValue', literalValue) + } + /> + + form.setValue('literalDatatypeIri', literalDatatypeIri) + } + /> +
    + ) : null} + + {usesExpressions ? ( +
    + {values.expressions.map((expression, index) => ( + + form.setValue( + 'expressions', + values.expressions.map((current, position) => + position === index ? next : current + ), + { shouldDirty: true } + ) + } + onRemove={() => + form.setValue( + 'expressions', + values.expressions.filter( + (_, position) => position !== index + ), + { shouldDirty: true } + ) + } + /> + ))} + +
    + ) : null} + + {profile ? : null} +
    + + +
    + {created ? ( + + ) : null} +
    +
    + ); +}; + +export default OntologyAxiomEditor; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomWorkspace/OntologyAxiomWorkspace.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomWorkspace/OntologyAxiomWorkspace.tsx new file mode 100644 index 000000000000..47895261c5b0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomWorkspace/OntologyAxiomWorkspace.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { usePermissionProvider } from '../../../../context/PermissionProvider/PermissionProvider'; +import { ResourceEntity } from '../../../../enums/permissions.enum'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { listOntologyAxioms } from '../../../../rest/ontologyAPI'; +import { getDerivedPermissionFlags } from '../../../../utils/PermissionDerivation'; +import { DEFAULT_ENTITY_PERMISSION } from '../../../../utils/PermissionsUtils'; +import OntologyDefinitionList from '../OntologyAuthoringWorkspace/OntologyDefinitionList'; +import OntologyAxiomEditor from '../OntologyAxiomEditor/OntologyAxiomEditor'; + +const OntologyAxiomWorkspace = ({ ontology }: { ontology: Ontology }) => { + const { permissions } = usePermissionProvider(); + const { canCreate } = getDerivedPermissionFlags( + permissions[ResourceEntity.ONTOLOGY_AXIOM] ?? DEFAULT_ENTITY_PERMISSION, + ontology?.deleted + ); + + return ( + listOntologyAxioms(ontology.id, params, signal)} + ontology={ontology}> + {(axiom, canEdit, onSaved) => ( + + )} + + ); +}; + +export default OntologyAxiomWorkspace; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.test.tsx new file mode 100644 index 000000000000..eb66c4b3d880 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.test.tsx @@ -0,0 +1,430 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { applyPatch, Operation } from 'fast-json-patch'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { + ConceptMappingType, + OntologyConcept, + OntologyConceptType, +} from '../../../../generated/entity/data/ontologyConcept'; +import APIClient from '../../../../rest/axiosClient'; +import { showErrorToast } from '../../../../utils/ToastUtils'; +import OntologyConceptEditor from './OntologyConceptEditor'; + +jest.mock('../../../../rest/axiosClient', () => ({ + post: jest.fn(), + patch: jest.fn(), +})); +jest.mock('../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +const post = APIClient.post as jest.MockedFunction; +const patch = APIClient.patch as jest.MockedFunction; +const ontology: Ontology = { + id: 'ontology-id', + name: 'Sales', + fullyQualifiedName: 'Sales', + iri: 'urn:sales', + namespace: 'urn:sales:', +}; +const concept: OntologyConcept = { + id: 'concept-id', + name: 'Customer', + fullyQualifiedName: 'Sales.Customer', + iri: 'urn:sales:customer', + conceptType: OntologyConceptType.OwlClass, + ontology: { id: ontology.id, type: 'ontology' }, + version: 0.1, + owners: [{ id: 'owner', type: 'user' }], + synonyms: ['Client'], +}; + +describe('OntologyConceptEditor', () => { + beforeEach(() => jest.clearAllMocks()); + + it('saves the selected vocabulary kind as a schema enum', async () => { + post.mockResolvedValue({ data: concept }); + render( + + ); + + expect( + screen.getByRole('button', { name: /label\.type$/ }) + ).toHaveTextContent('OWL_CLASS'); + + fireEvent.click(screen.getByRole('button', { name: /label\.type$/ })); + fireEvent.click( + await screen.findByRole('option', { name: 'SKOS_CONCEPT' }) + ); + fireEvent.change(screen.getByRole('textbox', { name: 'label.name' }), { + target: { value: 'Customer' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + await waitFor(() => + expect(post).toHaveBeenCalledWith( + '/ontologyConcepts', + expect.objectContaining({ + conceptType: OntologyConceptType.SkosConcept, + }) + ) + ); + }); + + it('creates an independent concept and leaves IRI minting to the server', async () => { + const saved = jest.fn(); + post.mockResolvedValue({ data: concept }); + render( + + ); + + fireEvent.change(screen.getByRole('textbox', { name: 'label.name' }), { + target: { value: ' Customer ' }, + }); + fireEvent.change( + screen.getByRole('textbox', { name: 'label.description' }), + { target: { value: 'A customer' } } + ); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + + await waitFor(() => expect(saved).toHaveBeenCalledWith(concept)); + + expect(post).toHaveBeenCalledWith('/ontologyConcepts', { + name: 'Customer', + ontology: 'Sales', + conceptType: OntologyConceptType.OwlClass, + displayName: undefined, + description: 'A customer', + iri: undefined, + synonyms: [], + conceptMappings: [], + }); + }); + + it('updates editable fields with a version precondition and preserves unrelated metadata', async () => { + patch.mockImplementation(async (_path, operations) => ({ + data: applyPatch(structuredClone(concept), operations as Operation[]) + .newDocument, + })); + const saved = jest.fn(); + render( + + ); + + expect(screen.getByRole('textbox', { name: 'label.name' })).toBeDisabled(); + expect( + screen.getByRole('textbox', { name: 'label.concept-iri' }) + ).toBeDisabled(); + + fireEvent.change( + screen.getByRole('textbox', { name: 'label.display-name' }), + { target: { value: 'Customers' } } + ); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + + await waitFor(() => + expect(saved).toHaveBeenCalledWith( + expect.objectContaining({ + id: concept.id, + iri: concept.iri, + name: concept.name, + displayName: 'Customers', + owners: concept.owners, + synonyms: ['Client'], + }) + ) + ); + + expect(patch).toHaveBeenCalledWith( + '/ontologyConcepts/concept-id', + expect.arrayContaining([ + { op: 'test', path: '/version', value: 0.1 }, + { op: 'add', path: '/displayName', value: 'Customers' }, + ]), + expect.anything() + ); + }); + + it('rejects a relative IRI before saving', async () => { + render( + + ); + fireEvent.change(screen.getByRole('textbox', { name: 'label.name' }), { + target: { value: 'Customer' }, + }); + fireEvent.change( + screen.getByRole('textbox', { name: 'label.concept-iri' }), + { target: { value: 'relative/iri' } } + ); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + + expect( + await screen.findByText('message.invalid-concept-iri') + ).toBeInTheDocument(); + expect(post).not.toHaveBeenCalled(); + }); + + it('validates and saves external mappings with their scheme and source', async () => { + post.mockResolvedValue({ data: concept }); + const saved = jest.fn(); + render( + + ); + fireEvent.change(screen.getByRole('textbox', { name: 'label.name' }), { + target: { value: 'Customer' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'label.add-mapping' })); + const mapping = within(screen.getByTestId('ontology-concept-mapping')); + fireEvent.change( + mapping.getByRole('textbox', { name: 'label.concept-iri' }), + { + target: { value: 'relative/concept' }, + } + ); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + + expect( + await mapping.findByText('message.invalid-concept-iri') + ).toBeVisible(); + expect(post).not.toHaveBeenCalled(); + + fireEvent.change( + mapping.getByRole('textbox', { name: 'label.concept-iri' }), + { + target: { value: ' https://example.org/Customer ' }, + } + ); + fireEvent.change( + mapping.getByRole('textbox', { name: 'label.scheme-iri' }), + { + target: { value: 'https://example.org/vocabulary' }, + } + ); + fireEvent.change(mapping.getByRole('textbox', { name: 'label.source' }), { + target: { value: 'External vocabulary' }, + }); + await waitFor(() => + expect( + mapping.queryByText('message.invalid-concept-iri') + ).not.toBeInTheDocument() + ); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + await waitFor(() => + expect(post).toHaveBeenCalledWith( + '/ontologyConcepts', + expect.objectContaining({ + conceptMappings: [ + { + conceptIri: 'https://example.org/Customer', + mappingType: ConceptMappingType.ExactMatch, + schemeIri: 'https://example.org/vocabulary', + source: 'External vocabulary', + }, + ], + }) + ) + ); + }); + + it('edits and removes mappings without changing concept identity or ownership', async () => { + const mapped = { + ...concept, + conceptMappings: [ + { + conceptIri: 'urn:external:Customer', + mappingType: ConceptMappingType.CloseMatch, + schemeIri: 'urn:external:', + source: 'Standard', + }, + { + conceptIri: 'urn:retired:Customer', + mappingType: ConceptMappingType.RelatedMatch, + }, + ], + }; + patch.mockImplementation(async (_path, operations) => ({ + data: applyPatch(structuredClone(mapped), operations as Operation[]) + .newDocument, + })); + const saved = jest.fn(); + render( + + ); + const rows = screen.getAllByTestId('ontology-concept-mapping'); + const first = within(rows[0]); + + expect( + first.getByRole('textbox', { name: 'label.scheme-iri' }) + ).toHaveValue('urn:external:'); + + fireEvent.click( + first.getByRole('button', { name: /label\.mapping-type$/ }) + ); + fireEvent.click( + await screen.findByRole('option', { name: ConceptMappingType.ExactMatch }) + ); + fireEvent.click( + within(rows[1]).getByRole('button', { name: 'label.remove' }) + ); + + expect( + screen.getByRole('button', { name: 'label.add-mapping' }) + ).toHaveFocus(); + + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + await waitFor(() => + expect(saved).toHaveBeenCalledWith({ + ...mapped, + conceptMappings: [ + { + ...mapped.conceptMappings[0], + mappingType: ConceptMappingType.ExactMatch, + }, + ], + }) + ); + + expect(patch.mock.calls[0][1]).toEqual( + expect.arrayContaining([ + { op: 'test', path: '/version', value: concept.version }, + ]) + ); + }); + + it('retains mappings after a server validation failure', async () => { + patch.mockRejectedValue(new Error('Class concepts cannot use sameAs')); + const saved = jest.fn(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: 'label.add-mapping' })); + const mapping = within(screen.getByTestId('ontology-concept-mapping')); + fireEvent.change( + mapping.getByRole('textbox', { name: 'label.concept-iri' }), + { + target: { value: 'urn:external:Customer' }, + } + ); + fireEvent.click( + mapping.getByRole('button', { name: /label\.mapping-type$/ }) + ); + fireEvent.click( + await screen.findByRole('option', { name: ConceptMappingType.SameAs }) + ); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + await waitFor(() => expect(showErrorToast).toHaveBeenCalled()); + + expect(saved).not.toHaveBeenCalled(); + expect( + mapping.getByRole('textbox', { name: 'label.concept-iri' }) + ).toHaveValue('urn:external:Customer'); + expect( + mapping.getByRole('button', { name: /label\.mapping-type$/ }) + ).toHaveTextContent(ConceptMappingType.SameAs); + expect(screen.getByRole('button', { name: 'label.save' })).toBeEnabled(); + }); + + it('retains an edited definition when a concurrent update rejects the save', async () => { + const error = new Error('Version changed'); + patch.mockRejectedValue(error); + const saved = jest.fn(); + render( + + ); + fireEvent.change( + screen.getByRole('textbox', { name: 'label.display-name' }), + { target: { value: 'Customers' } } + ); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + await waitFor(() => expect(showErrorToast).toHaveBeenCalled()); + + expect(saved).not.toHaveBeenCalled(); + expect( + screen.getByRole('textbox', { name: 'label.display-name' }) + ).toHaveValue('Customers'); + expect(screen.getByRole('button', { name: 'label.save' })).toBeEnabled(); + }); + + it('does not permit writes without edit permission', () => { + render( + + ); + + expect(screen.getByRole('button', { name: 'label.save' })).toBeDisabled(); + expect( + screen.getByRole('textbox', { name: 'label.display-name' }) + ).toBeDisabled(); + expect(patch).not.toHaveBeenCalled(); + expect( + screen.getByRole('button', { name: 'label.add-mapping' }) + ).toBeDisabled(); + + const mapping = within(screen.getByTestId('ontology-concept-mapping')); + + expect( + mapping.getByRole('textbox', { name: 'label.concept-iri' }) + ).toBeDisabled(); + expect( + mapping.getByRole('textbox', { name: 'label.scheme-iri' }) + ).toBeDisabled(); + expect( + mapping.getByRole('textbox', { name: 'label.source' }) + ).toBeDisabled(); + expect( + mapping.getByRole('button', { name: /label\.mapping-type$/ }) + ).toBeDisabled(); + expect( + mapping.getByRole('button', { name: 'label.remove' }) + ).toBeDisabled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.tsx new file mode 100644 index 000000000000..a64c67bbc39d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.tsx @@ -0,0 +1,146 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Box, + Button, + getField, + HookForm, + Typography, +} from '@openmetadata/ui-core-components'; +import { isAxiosError } from 'axios'; +import { Fragment, useRef, useState } from 'react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { + ConceptMappingType, + OntologyConcept, +} from '../../../../generated/entity/data/ontologyConcept'; +import { + createOntologyConcept, + patchOntologyConcept, +} from '../../../../rest/ontologyAPI'; +import { showErrorToast } from '../../../../utils/ToastUtils'; +import { + conceptFormFields, + conceptFormValues, + conceptPatch, + conceptRequest, + mappingFormFields, + OntologyConceptFormValues, +} from './OntologyConceptEditor.utils'; + +interface OntologyConceptEditorProps { + ontology: Ontology; + concept?: OntologyConcept; + canEdit: boolean; + onSaved: (concept: OntologyConcept) => void; +} + +const OntologyConceptEditor = ({ + ontology, + concept, + canEdit, + onSaved, +}: OntologyConceptEditorProps) => { + const { t } = useTranslation(); + const form = useForm({ + defaultValues: conceptFormValues(concept), + }); + const [isSaving, setIsSaving] = useState(false); + const mappings = useFieldArray({ + control: form.control, + name: 'conceptMappings', + }); + const addMappingButton = useRef(null); + const disabled = !canEdit || isSaving; + + const save = async (values: OntologyConceptFormValues) => { + if (!canEdit) { + return; + } + setIsSaving(true); + try { + const request = conceptRequest(values, ontology.fullyQualifiedName); + const result = concept + ? await patchOntologyConcept(concept.id, conceptPatch(concept, request)) + : await createOntologyConcept(request); + onSaved(result); + } catch (error) { + showErrorToast( + isAxiosError(error) ? error : t('server.unexpected-error') + ); + } finally { + setIsSaving(false); + } + }; + + return ( + + + {conceptFormFields(t, Boolean(concept), disabled).map((field) => ( + {getField(field)} + ))} + + {t('label.mapping-plural')} + + {mappings.fields.map((mapping, index) => ( +
    + + {t('label.mapping')} {index + 1} + + + {mappingFormFields(t, index, disabled).map((field) => ( + {getField(field)} + ))} + + +
    + ))} + + +
    +
    + ); +}; + +export default OntologyConceptEditor; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.utils.ts new file mode 100644 index 000000000000..1d801aef234f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.utils.ts @@ -0,0 +1,223 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + FieldProp, + FieldTypes, + FormSelectItem, +} from '@openmetadata/ui-core-components'; +import { compare, Operation } from 'fast-json-patch'; +import { pick } from 'lodash'; +import type { useTranslation } from 'react-i18next'; +import { ENTITY_NAME_REGEX } from '../../../../constants/regex.constants'; +import { + ConceptMappingType, + CreateOntologyConcept, + OntologyConceptType, +} from '../../../../generated/api/data/createOntologyConcept'; +import { OntologyConcept } from '../../../../generated/entity/data/ontologyConcept'; +import { isAbsoluteOntologyIri } from '../../../../utils/governance/ontology/OntologyAuthoring.utils'; + +export interface OntologyConceptFormValues { + name: string; + displayName: string; + description: string; + iri: string; + conceptType: { id: OntologyConceptType; label: string }; + synonyms: FormSelectItem[]; + conceptMappings: { + conceptIri: string; + mappingType: { id: ConceptMappingType; label: string }; + schemeIri: string; + source: string; + }[]; +} + +export const conceptFormValues = ( + concept?: OntologyConcept +): OntologyConceptFormValues => ({ + name: concept?.name ?? '', + displayName: concept?.displayName ?? '', + description: concept?.description ?? '', + iri: concept?.iri ?? '', + conceptType: { + id: concept?.conceptType ?? OntologyConceptType.OwlClass, + label: concept?.conceptType ?? OntologyConceptType.OwlClass, + }, + synonyms: (concept?.synonyms ?? []).map((value) => ({ + id: value, + label: value, + })), + conceptMappings: (concept?.conceptMappings ?? []).map((mapping) => ({ + conceptIri: mapping.conceptIri, + mappingType: { id: mapping.mappingType, label: mapping.mappingType }, + schemeIri: mapping.schemeIri ?? '', + source: mapping.source ?? '', + })), +}); + +export const conceptRequest = ( + values: OntologyConceptFormValues, + ontology: string +): CreateOntologyConcept => ({ + name: values.name.trim(), + displayName: values.displayName.trim() || undefined, + description: values.description.trim() || undefined, + iri: values.iri.trim() || undefined, + conceptType: values.conceptType.id, + synonyms: values.synonyms.map((value) => String(value.id)), + conceptMappings: values.conceptMappings.map((mapping) => ({ + conceptIri: mapping.conceptIri.trim(), + mappingType: mapping.mappingType.id, + schemeIri: mapping.schemeIri.trim() || undefined, + source: mapping.source.trim() || undefined, + })), + ontology, +}); + +export const conceptPatch = ( + concept: OntologyConcept, + request: CreateOntologyConcept +): Operation[] => { + const fields = [ + 'displayName', + 'description', + 'conceptType', + 'synonyms', + 'conceptMappings', + ]; + const changes = compare(pick(concept, fields), pick(request, fields)); + + return concept.version === undefined + ? changes + : [{ op: 'test', path: '/version', value: concept.version }, ...changes]; +}; + +export const mappingFormFields = ( + t: ReturnType['t'], + index: number, + disabled: boolean +): FieldProp[] => [ + { + name: `conceptMappings.${index}.mappingType`, + label: t('label.mapping-type'), + type: FieldTypes.SELECT, + required: true, + props: { + isDisabled: disabled, + options: Object.values(ConceptMappingType).map((id) => ({ + id, + label: id, + })), + }, + }, + { + name: `conceptMappings.${index}.conceptIri`, + label: t('label.concept-iri'), + type: FieldTypes.TEXT, + required: true, + props: { isDisabled: disabled }, + rules: { + validate: (value: string) => + isAbsoluteOntologyIri(value.trim()) || t('message.invalid-concept-iri'), + }, + }, + { + name: `conceptMappings.${index}.schemeIri`, + label: t('label.scheme-iri'), + type: FieldTypes.TEXT, + props: { isDisabled: disabled }, + rules: { + validate: (value: string) => + !value.trim() || + isAbsoluteOntologyIri(value.trim()) || + t('message.invalid-concept-iri'), + }, + }, + { + name: `conceptMappings.${index}.source`, + label: t('label.source'), + type: FieldTypes.TEXT, + props: { isDisabled: disabled }, + }, +]; + +export const conceptFormFields = ( + t: ReturnType['t'], + editing: boolean, + disabled: boolean +): FieldProp[] => { + const fields: FieldProp[] = [ + { + name: 'name', + label: t('label.name'), + type: FieldTypes.TEXT, + required: true, + props: { isDisabled: editing }, + rules: { + validate: (value: string) => + Boolean(value.trim()) || + t('label.field-required', { field: t('label.name') }), + pattern: { + value: ENTITY_NAME_REGEX, + message: t('message.entity-name-validation'), + }, + }, + }, + { + name: 'displayName', + label: t('label.display-name'), + type: FieldTypes.TEXT, + }, + { + name: 'description', + label: t('label.description'), + type: FieldTypes.TEXTAREA, + }, + { + name: 'conceptType', + label: t('label.type'), + type: FieldTypes.SELECT, + required: true, + props: { + options: Object.values(OntologyConceptType).map((id) => ({ + id, + label: id, + })), + }, + }, + { + name: 'iri', + label: t('label.concept-iri'), + type: FieldTypes.TEXT, + props: { isDisabled: editing }, + rules: { + validate: (value: string) => + !value.trim() || + isAbsoluteOntologyIri(value.trim()) || + t('message.invalid-concept-iri'), + }, + }, + { + name: 'synonyms', + label: t('label.synonym-plural'), + type: FieldTypes.MULTI_SELECT, + placeholder: t('message.synonym-placeholder'), + props: { allowsCreation: true, hideDropdown: true, items: [] }, + }, + ]; + + return fields.map((field) => ({ + ...field, + props: { ...field.props, isDisabled: disabled || field.props?.isDisabled }, + })); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.test.tsx new file mode 100644 index 000000000000..5afedf006d47 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.test.tsx @@ -0,0 +1,184 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Layer } from '../../../../generated/api/data/createOntology'; +import APIClient from '../../../../rest/axiosClient'; +import { + renderWithQueryClient, + runQueryNotificationsSynchronously, +} from '../../../../test/unit/test-utils'; +import OntologyCreateForm from './OntologyCreateForm'; + +jest.mock('../../../../rest/axiosClient', () => ({ + post: jest.fn(), + patch: jest.fn(), + get: jest.fn(), +})); +jest.mock('../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); +const post = APIClient.post as jest.MockedFunction; +const get = APIClient.get as jest.MockedFunction; + +runQueryNotificationsSynchronously(); + +beforeAll(() => jest.useRealTimers()); + +afterAll(() => jest.useFakeTimers()); + +describe('OntologyCreateForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + post.mockResolvedValue({ data: { id: 'ontology-id', name: 'Sales' } }); + get.mockResolvedValue({ data: { hits: { hits: [] } } }); + }); + + it('edits ontology metadata while preserving its identity and reviewers', async () => { + const user = userEvent.setup(); + const ontology = { + id: 'sales', + name: 'Sales', + fullyQualifiedName: 'Sales', + iri: 'urn:sales', + namespace: 'urn:sales:', + version: 0.3, + reviewers: [{ id: 'reviewer', type: 'user', name: 'Reviewer' }], + }; + const saved = jest.fn(); + const patch = APIClient.patch as jest.MockedFunction< + typeof APIClient.patch + >; + patch.mockResolvedValue({ + data: { ...ontology, displayName: 'Sales model' }, + }); + renderWithQueryClient( + + ); + + expect(screen.getByRole('textbox', { name: 'label.name' })).toHaveValue( + 'Sales' + ); + expect(screen.getByRole('textbox', { name: 'label.name' })).toBeDisabled(); + + await user.type( + screen.getByRole('textbox', { name: 'label.display-name' }), + 'Sales model' + ); + fireEvent.submit(screen.getByTestId('ontology-create-form')); + + await waitFor(() => + expect(saved).toHaveBeenCalledWith( + expect.objectContaining({ displayName: 'Sales model' }) + ) + ); + + expect(patch).toHaveBeenCalledWith( + '/ontologies/sales', + expect.arrayContaining([ + { op: 'test', path: '/version', value: 0.3 }, + { op: 'add', path: '/displayName', value: 'Sales model' }, + ]), + expect.anything() + ); + expect(post).not.toHaveBeenCalled(); + }); + + it('requires ontology identity and assigns the creator as owner', async () => { + const user = userEvent.setup(); + const saved = jest.fn(); + renderWithQueryClient( + + ); + await act(async () => { + fireEvent.submit(screen.getByTestId('ontology-create-form')); + }); + + expect(await screen.findAllByText('label.field-required')).toHaveLength(3); + expect(post).not.toHaveBeenCalled(); + + await user.type( + screen.getByRole('textbox', { name: 'label.name' }), + 'Sales' + ); + await user.type( + screen.getByRole('textbox', { name: 'label.ontology-iri' }), + 'urn:sales' + ); + await user.type( + screen.getByRole('textbox', { name: 'label.namespace' }), + 'urn:sales:' + ); + await act(async () => { + fireEvent.submit(screen.getByTestId('ontology-create-form')); + }); + + await waitFor(() => + expect(saved).toHaveBeenCalledWith({ id: 'ontology-id', name: 'Sales' }) + ); + + expect(post).toHaveBeenCalledWith( + '/ontologies', + expect.objectContaining({ + name: 'Sales', + iri: 'urn:sales', + namespace: 'urn:sales:', + layer: Layer.L1, + owners: [{ id: 'author', type: 'user' }], + reviewers: [], + }) + ); + }); + + it('keeps invalid namespace input visible without writing', async () => { + const user = userEvent.setup(); + renderWithQueryClient( + + ); + await user.type( + screen.getByRole('textbox', { name: 'label.name' }), + 'Sales' + ); + await user.type( + screen.getByRole('textbox', { name: 'label.ontology-iri' }), + 'urn:sales' + ); + await user.type( + screen.getByRole('textbox', { name: 'label.namespace' }), + 'relative/path' + ); + await act(async () => { + fireEvent.submit(screen.getByTestId('ontology-create-form')); + }); + + expect( + await screen.findByText('message.invalid-concept-iri') + ).toBeInTheDocument(); + expect( + screen.getByRole('textbox', { name: 'label.namespace' }) + ).toHaveValue('relative/path'); + expect(post).not.toHaveBeenCalled(); + }); + + it('prevents creation when the permission is denied', () => { + renderWithQueryClient( + + ); + + expect(screen.getByRole('button', { name: 'label.save' })).toBeDisabled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.tsx new file mode 100644 index 000000000000..1b5738a46fd0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.tsx @@ -0,0 +1,98 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Box, + Button, + getField, + HookForm, +} from '@openmetadata/ui-core-components'; +import { isAxiosError } from 'axios'; +import { Fragment, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { createOntology, patchOntology } from '../../../../rest/ontologyAPI'; +import { showErrorToast } from '../../../../utils/ToastUtils'; +import { useUserTeamOptions } from '../../../Glossary/hooks/useEntityReferenceOptions'; +import { + ontologyFormFields, + ontologyFormValues, + OntologyFormValues, + ontologyPatch, + ontologyRequest, +} from './OntologyCreateForm.utils'; + +interface OntologyCreateFormProps { + canCreate: boolean; + ontology?: Ontology; + currentUserId?: string; + onSaved: (ontology: Ontology) => void; +} + +const OntologyCreateForm = ({ + canCreate, + ontology, + currentUserId, + onSaved, +}: OntologyCreateFormProps) => { + const { t } = useTranslation(); + const owners = useUserTeamOptions(); + const reviewers = useUserTeamOptions(); + const form = useForm({ + defaultValues: ontologyFormValues(ontology), + }); + const [isSaving, setIsSaving] = useState(false); + const save = async (values: OntologyFormValues) => { + if (!canCreate) { + return; + } + setIsSaving(true); + try { + onSaved( + ontology + ? await patchOntology(ontology.id, ontologyPatch(ontology, values)) + : await createOntology(ontologyRequest(values, currentUserId)) + ); + } catch (error) { + showErrorToast( + isAxiosError(error) ? error : t('server.unexpected-error') + ); + } finally { + setIsSaving(false); + } + }; + + return ( + + + {ontologyFormFields( + t, + owners, + reviewers, + !canCreate || isSaving, + Boolean(ontology) + ).map((field) => ( + {getField(field)} + ))} + + + + ); +}; + +export default OntologyCreateForm; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.utils.ts new file mode 100644 index 000000000000..910ec8c2f318 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.utils.ts @@ -0,0 +1,184 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { FieldProp, FieldTypes } from '@openmetadata/ui-core-components'; +import { compare, Operation } from 'fast-json-patch'; +import { isUndefined, omitBy, pick } from 'lodash'; +import type { useTranslation } from 'react-i18next'; +import { + CreateOntology, + Layer, +} from '../../../../generated/api/data/createOntology'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { isAbsoluteOntologyIri } from '../../../../utils/governance/ontology/OntologyAuthoring.utils'; +import { EntityReferenceOption } from '../../../Glossary/AddGlossary/AddGlossary.interface'; +import { + getOwnersOrCurrentUser, + toEntityReferenceOption, + toEntityReferences, +} from '../../../Glossary/AddGlossary/AddGlossary.utils'; +import { EntityReferencePicker } from '../../../Glossary/hooks/useEntityReferenceOptions'; + +export interface OntologyFormValues { + name: string; + displayName: string; + description: string; + iri: string; + namespace: string; + layer: { id: Layer; label: string }; + owners: EntityReferenceOption[]; + reviewers: EntityReferenceOption[]; +} + +export const ontologyFormValues = ( + ontology?: Ontology +): OntologyFormValues => ({ + name: ontology?.name ?? '', + displayName: ontology?.displayName ?? '', + description: ontology?.description ?? '', + iri: ontology?.iri ?? '', + namespace: ontology?.namespace ?? '', + layer: { + id: ontology?.layer ?? Layer.L1, + label: ontology?.layer ?? Layer.L1, + }, + owners: (ontology?.owners ?? []).map(toEntityReferenceOption), + reviewers: (ontology?.reviewers ?? []).map(toEntityReferenceOption), +}); + +export const ontologyRequest = ( + values: OntologyFormValues, + currentUserId?: string +): CreateOntology => ({ + name: values.name.trim(), + displayName: values.displayName.trim() || undefined, + description: values.description.trim() || undefined, + iri: values.iri.trim(), + namespace: values.namespace.trim(), + layer: values.layer.id, + owners: getOwnersOrCurrentUser( + toEntityReferences(values.owners), + currentUserId + ), + reviewers: toEntityReferences(values.reviewers), +}); + +export const ontologyPatch = ( + ontology: Ontology, + values: OntologyFormValues +): Operation[] => { + const fields = [ + 'displayName', + 'description', + 'namespace', + 'layer', + 'owners', + 'reviewers', + ]; + + return [ + { op: 'test', path: '/version', value: ontology.version }, + ...compare( + omitBy( + pick(ontologyRequest(ontologyFormValues(ontology)), fields), + isUndefined + ), + omitBy(pick(ontologyRequest(values), fields), isUndefined) + ), + ]; +}; + +export const ontologyFormFields = ( + t: ReturnType['t'], + owners: EntityReferencePicker, + reviewers: EntityReferencePicker, + disabled: boolean, + editing = false +): FieldProp[] => { + const iriRule = (value: string) => + !value.trim() || + isAbsoluteOntologyIri(value.trim()) || + t('message.invalid-concept-iri'); + const fields: FieldProp[] = [ + { + name: 'name', + label: t('label.name'), + type: FieldTypes.TEXT, + required: true, + rules: { + validate: (value: string) => + Boolean(value.trim()) || + t('label.field-required', { field: t('label.name') }), + }, + }, + { + name: 'displayName', + label: t('label.display-name'), + type: FieldTypes.TEXT, + }, + { + name: 'description', + label: t('label.description'), + type: FieldTypes.TEXTAREA, + }, + { + name: 'iri', + label: t('label.ontology-iri'), + type: FieldTypes.TEXT, + required: true, + rules: { validate: iriRule }, + }, + { + name: 'namespace', + label: t('label.namespace'), + type: FieldTypes.TEXT, + required: true, + rules: { validate: iriRule }, + }, + { + name: 'layer', + label: t('label.layer'), + type: FieldTypes.SELECT, + required: true, + props: { options: Object.values(Layer).map((id) => ({ id, label: id })) }, + }, + ...[ + { name: 'owners', label: t('label.owner-plural'), picker: owners }, + { + name: 'reviewers', + label: t('label.reviewer-plural'), + picker: reviewers, + }, + ].map( + ({ name, label, picker }): FieldProp => ({ + name, + label, + type: FieldTypes.USER_TEAM_SELECT_INPUT, + props: { multiple: true, filterOption: () => true, ...picker }, + }) + ), + ]; + + return fields.map((field) => ({ + ...field, + props: { + ...field.props, + isDisabled: disabled || (editing && ['name', 'iri'].includes(field.name)), + }, + rules: { + ...field.rules, + ...(field.required + ? { required: t('label.field-required', { field: field.label }) } + : {}), + }, + })); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyGraphView/OntologyGraphView.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyGraphView/OntologyGraphView.tsx new file mode 100644 index 000000000000..c35b299a89cb --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyGraphView/OntologyGraphView.tsx @@ -0,0 +1,187 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Alert, + Box, + Button, + Typography, +} from '@openmetadata/ui-core-components'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { lazy, Suspense, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { listOntologyConcepts } from '../../../../rest/ontologyAPI'; +import { buildNativeOntologyGraph } from '../../../../utils/governance/ontology/OntologyGraph.utils'; +import OntologyControlButtons from '../../../OntologyExplorer/OntologyControlButtons'; +import { LayoutType } from '../../../OntologyExplorer/OntologyExplorer.constants'; +import { OntologyGraphHandle } from '../../../OntologyExplorer/OntologyExplorer.interface'; +import { + buildOntologyTreeGroups, + buildOntologyTreeMetrics, +} from '../../../OntologyExplorer/OntologyStudio.utils'; +import OntologyTreeView from '../../../OntologyExplorer/OntologyTreeView'; +import OntologyConceptEditor from '../OntologyConceptEditor/OntologyConceptEditor'; + +const OntologyGraphG6 = lazy( + () => import('../../../OntologyExplorer/OntologyGraphG6') +); +const GRAPH_LIMIT = 10000; +const FILTERS = { + viewMode: 'hierarchy' as const, + glossaryIds: [], + relationTypes: [], + searchQuery: '', + showIsolatedNodes: true, + showCrossGlossaryOnly: false, +}; +const SETTINGS = { layout: LayoutType.Hierarchical, showEdgeLabels: true }; + +const OntologyGraphView = ({ ontology }: { ontology: Ontology }) => { + const { t } = useTranslation(); + const graphRef = useRef(null); + const [surface, setSurface] = useState<'graph' | 'tree'>('graph'); + const [selectedId, setSelectedId] = useState(); + const query = useInfiniteQuery({ + queryKey: ['ontology-studio-concepts', ontology.id], + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + listOntologyConcepts( + { + ontology: ontology.fullyQualifiedName, + fields: 'superClasses', + limit: 500, + after: pageParam, + }, + signal + ), + getNextPageParam: (page) => page.paging.after, + retry: false, + gcTime: 0, + }); + const concepts = useMemo( + () => query.data?.pages.flatMap((page) => page.data) ?? [], + [query.data] + ); + const graph = useMemo( + () => buildNativeOntologyGraph(ontology, concepts), + [ontology, concepts] + ); + const groups = useMemo( + () => buildOntologyTreeGroups(graph, FILTERS, [], []), + [graph] + ); + const selected = concepts.find((concept) => concept.id === selectedId); + if (query.isError) { + return ( + + {t('server.entity-fetch-error', { entity: t('label.concept-plural') })} + + + ); + } + if (query.isPending) { + return {t('label.loading')}; + } + + return ( + + + {(['graph', 'tree'] as const).map((view) => ( + + ))} + + {concepts.length} / {query.data.pages[0].paging.total}{' '} + {t('label.concept-plural')} + + {query.hasNextPage && ( + + )} + void graphRef.current?.fitView()} + onRefresh={() => void query.refetch()} + onZoomIn={() => graphRef.current?.zoomIn()} + onZoomOut={() => graphRef.current?.zoomOut()} + /> + + {query.hasNextPage && concepts.length >= GRAPH_LIMIT && ( + + {t('message.ontology-graph-limit', { count: GRAPH_LIMIT })} + + )} + + + {surface === 'tree' ? ( + setSelectedId(node.id)} + onSelect={(node) => setSelectedId(node.id)} + /> + ) : ( + {t('label.loading')}
    + }> + setSelectedId(node.id)} + onNodeDoubleClick={(node) => setSelectedId(node.id)} + onPaneClick={() => setSelectedId(undefined)} + /> + + )} + + {selected && ( + + + void query.refetch()} + /> + + )} + + + ); +}; + +export default OntologyGraphView; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPicker/OntologyPicker.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPicker/OntologyPicker.tsx new file mode 100644 index 000000000000..6cfc9458d361 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPicker/OntologyPicker.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Button, + TreeSelect, + TreeSelectDataFetcher, +} from '@openmetadata/ui-core-components'; +import { isAxiosError } from 'axios'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { listOntologies } from '../../../../rest/ontologyAPI'; +import { getEntityName } from '../../../../utils/EntityNameUtils'; +import { showErrorToast } from '../../../../utils/ToastUtils'; + +interface OntologyPickerProps { + value?: Ontology; + onChange: (ontology?: Ontology) => void; +} + +const OntologyPicker = ({ value, onChange }: OntologyPickerProps) => { + const { t } = useTranslation(); + const fetchData = useCallback>( + async ({ after, pageSize = 25, signal }) => { + const page = await listOntologies( + { limit: pageSize, after, fields: 'owners,reviewers' }, + signal + ); + + return { + nodes: page.data.map((ontology) => ({ + id: ontology.id, + value: ontology.id, + label: getEntityName(ontology), + data: ontology, + isLeaf: true, + })), + total: page.paging.total, + nextCursor: page.paging.after, + hasMore: Boolean(page.paging.after), + }; + }, + [] + ); + + return ( + ( + + )} + value={ + value + ? { + id: value.id, + value: value.id, + label: getEntityName(value), + data: value, + } + : null + } + onChange={(node) => + onChange((Array.isArray(node) ? node[0] : node)?.data) + } + onFetchError={(error) => + showErrorToast( + isAxiosError(error) + ? error + : t('server.entity-fetch-error', { entity: t('label.ontology') }) + ) + } + /> + ); +}; + +export default OntologyPicker; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.test.tsx new file mode 100644 index 000000000000..e3fd6a6c7b74 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.test.tsx @@ -0,0 +1,268 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { applyPatch, Operation } from 'fast-json-patch'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { + Kind, + OntologyProperty, +} from '../../../../generated/entity/data/ontologyProperty'; +import APIClient from '../../../../rest/axiosClient'; +import { showErrorToast } from '../../../../utils/ToastUtils'; +import OntologyPropertyEditor from './OntologyPropertyEditor'; + +jest.mock('../../../../rest/axiosClient', () => ({ + post: jest.fn(), + patch: jest.fn(), +})); +jest.mock('../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +const post = APIClient.post as jest.MockedFunction; +const patch = APIClient.patch as jest.MockedFunction; +const ontology: Ontology = { + id: 'ontology-id', + name: 'Sales', + fullyQualifiedName: 'Sales', + iri: 'urn:sales', + namespace: 'urn:sales:', +}; +const property: OntologyProperty = { + id: 'property-id', + name: 'status', + fullyQualifiedName: 'Sales.status', + iri: 'urn:sales:status', + kind: Kind.Data, + ontology: { id: ontology.id, type: 'ontology' }, + version: 0.2, + owners: [{ id: 'owner', type: 'user' }], + domain: ['urn:sales:customer'], + range: ['http://www.w3.org/2001/XMLSchema#string'], + enumValues: [ + { value: 'active', datatypeIri: 'http://www.w3.org/2001/XMLSchema#string' }, + ], +}; + +describe('OntologyPropertyEditor', () => { + beforeEach(() => jest.clearAllMocks()); + + it('creates an object property in its native ontology with a server-minted IRI', async () => { + post.mockResolvedValue({ data: { ...property, kind: Kind.Object } }); + const saved = jest.fn(); + render( + + ); + fireEvent.change(screen.getByRole('textbox', { name: 'label.name' }), { + target: { value: 'purchasedBy' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + + await waitFor(() => expect(saved).toHaveBeenCalled()); + + expect(post).toHaveBeenCalledWith( + '/ontologyProperties', + expect.objectContaining({ + name: 'purchasedBy', + kind: Kind.Object, + ontology: 'Sales', + iri: undefined, + }) + ); + }); + + it('edits a typed literal without changing identity, ownership or the datatype', async () => { + patch.mockImplementation(async (_path, operations) => ({ + data: applyPatch(structuredClone(property), operations as Operation[]) + .newDocument, + })); + const saved = jest.fn(); + render( + + ); + + expect(screen.getByRole('textbox', { name: 'label.name' })).toBeDisabled(); + expect( + screen.getByRole('textbox', { name: 'label.property-iri' }) + ).toBeDisabled(); + + fireEvent.change(screen.getByRole('textbox', { name: /^label\.value/ }), { + target: { value: ' pending ' }, + }); + fireEvent.blur(screen.getByRole('textbox', { name: /^label\.value/ })); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + + await waitFor(() => + expect(saved).toHaveBeenCalledWith( + expect.objectContaining({ + id: property.id, + iri: property.iri, + owners: property.owners, + enumValues: [ + { + value: ' pending ', + datatypeIri: property.enumValues?.[0].datatypeIri, + }, + ], + }) + ) + ); + + expect(patch).toHaveBeenCalledWith( + '/ontologyProperties/property-id', + expect.arrayContaining([{ op: 'test', path: '/version', value: 0.2 }]), + expect.anything() + ); + }); + + it('preserves property-chain order and repeated IRIs', async () => { + post.mockResolvedValue({ data: property }); + render( + + ); + fireEvent.change(screen.getByRole('textbox', { name: 'label.name' }), { + target: { value: 'walk' }, + }); + fireEvent.change( + screen.getByRole('textbox', { name: 'label.property-chain' }), + { target: { value: 'urn:step\nurn:turn\nurn:step' } } + ); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + + await waitFor(() => + expect(post).toHaveBeenCalledWith( + '/ontologyProperties', + expect.objectContaining({ + propertyChain: ['urn:step', 'urn:turn', 'urn:step'], + }) + ) + ); + }); + + it('does not offer object or data constraints on annotation properties', async () => { + post.mockResolvedValue({ data: { ...property, kind: Kind.Annotation } }); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /label\.type$/ })); + fireEvent.click(await screen.findByRole('option', { name: 'ANNOTATION' })); + + expect( + screen.queryByRole('textbox', { name: 'label.property-chain' }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('textbox', { name: 'label.unit' }) + ).not.toBeInTheDocument(); + + fireEvent.change(screen.getByRole('textbox', { name: 'label.name' }), { + target: { value: 'note' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + + await waitFor(() => + expect(post).toHaveBeenCalledWith( + '/ontologyProperties', + expect.objectContaining({ kind: Kind.Annotation }) + ) + ); + }); + + it('does not send hidden constraints after changing a new property kind', async () => { + post.mockResolvedValue({ data: property }); + render( + + ); + fireEvent.change(screen.getByRole('textbox', { name: 'label.name' }), { + target: { value: 'status' }, + }); + fireEvent.change(screen.getByRole('textbox', { name: 'label.inverse' }), { + target: { value: 'urn:inverse' }, + }); + fireEvent.click(screen.getByRole('button', { name: /label\.type$/ })); + fireEvent.click(await screen.findByRole('option', { name: 'DATA' })); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + await waitFor(() => expect(post).toHaveBeenCalled()); + + expect(post.mock.calls[0][1]).not.toHaveProperty('inverse'); + expect(post.mock.calls[0][1]).not.toHaveProperty('propertyChain'); + }); + + it('rejects a relative literal datatype before writing', async () => { + render( + + ); + fireEvent.change(screen.getByRole('textbox', { name: 'label.data-type' }), { + target: { value: 'relative/type' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + + expect( + await screen.findByText('message.invalid-concept-iri') + ).toBeInTheDocument(); + expect(patch).not.toHaveBeenCalled(); + }); + + it('retains edits after a rejected concurrent update', async () => { + patch.mockRejectedValue(new Error('Version changed')); + render( + + ); + fireEvent.change( + screen.getByRole('textbox', { name: 'label.display-name' }), + { target: { value: 'Customer status' } } + ); + fireEvent.click(screen.getByRole('button', { name: 'label.save' })); + + await waitFor(() => expect(showErrorToast).toHaveBeenCalled()); + + expect( + screen.getByRole('textbox', { name: 'label.display-name' }) + ).toHaveValue('Customer status'); + expect(screen.getByRole('button', { name: 'label.save' })).toBeEnabled(); + }); + + it('gates writes and literal controls on edit permission', () => { + render( + + ); + + expect(screen.getByRole('button', { name: 'label.save' })).toBeDisabled(); + expect( + screen.getByRole('textbox', { name: /^label\.value/ }) + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'label.add-entity' }) + ).toBeDisabled(); + expect(screen.getByRole('button', { name: 'label.remove' })).toBeDisabled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.tsx new file mode 100644 index 000000000000..4eb700850619 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.tsx @@ -0,0 +1,160 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Box, + Button, + FieldTypes, + FormField, + getField, + HookForm, + Input, + Typography, +} from '@openmetadata/ui-core-components'; +import { isAxiosError } from 'axios'; +import { Fragment, useState } from 'react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { + Kind, + OntologyProperty, +} from '../../../../generated/entity/data/ontologyProperty'; +import { + createOntologyProperty, + patchOntologyProperty, +} from '../../../../rest/ontologyAPI'; +import { isAbsoluteOntologyIri } from '../../../../utils/governance/ontology/OntologyAuthoring.utils'; +import { showErrorToast } from '../../../../utils/ToastUtils'; +import { + OntologyPropertyFormValues, + propertyFormFields, + propertyFormValues, + propertyPatch, + propertyRequest, +} from './OntologyPropertyEditor.utils'; + +interface OntologyPropertyEditorProps { + ontology: Ontology; + property?: OntologyProperty; + canEdit: boolean; + onSaved: (property: OntologyProperty) => void; +} + +const OntologyPropertyEditor = ({ + ontology, + property, + canEdit, + onSaved, +}: OntologyPropertyEditorProps) => { + const { t } = useTranslation(); + const form = useForm({ + defaultValues: propertyFormValues(property), + }); + const literals = useFieldArray({ control: form.control, name: 'enumValues' }); + const kind = form.watch('kind').id; + const [isSaving, setIsSaving] = useState(false); + const disabled = !canEdit || isSaving; + const save = async (values: OntologyPropertyFormValues) => { + if (!canEdit) { + return; + } + setIsSaving(true); + try { + const request = propertyRequest(values, ontology.fullyQualifiedName); + onSaved( + property + ? await patchOntologyProperty( + property.id, + propertyPatch(property, request) + ) + : await createOntologyProperty(request) + ); + } catch (error) { + showErrorToast( + isAxiosError(error) ? error : t('server.unexpected-error') + ); + } finally { + setIsSaving(false); + } + }; + + return ( + + + {propertyFormFields(t, kind, Boolean(property), disabled).map( + (field) => ( + {getField(field)} + ) + )} + {kind === Kind.Data && ( + + + {t('label.enum-value-plural')} + + {literals.fields.map((literal, index) => ( + + + {({ field }) => ( + + )} + + {getField({ + name: `enumValues.${index}.datatypeIri`, + label: t('label.data-type'), + type: FieldTypes.TEXT, + props: { isDisabled: disabled }, + rules: { + validate: (value: string) => + !value.trim() || + isAbsoluteOntologyIri(value.trim()) || + t('message.invalid-concept-iri'), + }, + })} + + + ))} + + + )} + + + + ); +}; + +export default OntologyPropertyEditor; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.utils.ts new file mode 100644 index 000000000000..4cf4c733aae8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.utils.ts @@ -0,0 +1,254 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { FieldProp, FieldTypes } from '@openmetadata/ui-core-components'; +import { compare, Operation } from 'fast-json-patch'; +import { pick } from 'lodash'; +import type { useTranslation } from 'react-i18next'; +import { ENTITY_NAME_REGEX } from '../../../../constants/regex.constants'; +import { + Characteristic, + CreateOntologyProperty, + Kind, +} from '../../../../generated/api/data/createOntologyProperty'; +import { OntologyProperty } from '../../../../generated/entity/data/ontologyProperty'; +import { isAbsoluteOntologyIri } from '../../../../utils/governance/ontology/OntologyAuthoring.utils'; + +export interface OntologyPropertyFormValues { + name: string; + displayName: string; + description: string; + iri: string; + kind: { id: Kind; label: string }; + domain: string; + range: string; + inverse: string; + propertyChain: string; + disjointWith: string; + characteristics: { id: Characteristic; label: string }[]; + enumValues: { value: string; datatypeIri: string }[]; + unit: string; + isIdentifier: boolean; +} + +export const propertyFormValues = ( + property: Partial = {} +): OntologyPropertyFormValues => { + const { + name = '', + displayName = '', + description = '', + iri = '', + kind = Kind.Object, + domain = [], + range = [], + inverse = '', + propertyChain = [], + disjointWith = [], + characteristics = [], + enumValues = [], + unit = '', + isIdentifier = false, + } = property; + + return { + name, + displayName, + description, + iri, + kind: { id: kind, label: kind }, + domain: domain.join('\n'), + range: range.join('\n'), + inverse, + propertyChain: propertyChain.join('\n'), + disjointWith: disjointWith.join('\n'), + characteristics: characteristics.map((id) => ({ id, label: id })), + enumValues: enumValues.map((value) => ({ + ...value, + datatypeIri: value.datatypeIri ?? '', + })), + unit, + isIdentifier, + }; +}; + +const iriLines = (value: string) => + value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + +export const propertyRequest = ( + values: OntologyPropertyFormValues, + ontology: string +): CreateOntologyProperty => ({ + name: values.name.trim(), + displayName: values.displayName.trim() || undefined, + description: values.description.trim() || undefined, + iri: values.iri.trim() || undefined, + kind: values.kind.id, + ontology, + domain: iriLines(values.domain), + range: iriLines(values.range), + ...(values.kind.id !== Kind.Annotation && { + disjointWith: iriLines(values.disjointWith), + characteristics: values.characteristics + .map((item) => item.id) + .filter( + (value) => + values.kind.id === Kind.Object || value === Characteristic.Functional + ), + }), + ...(values.kind.id === Kind.Object && { + inverse: values.inverse.trim() || undefined, + propertyChain: iriLines(values.propertyChain), + }), + ...(values.kind.id === Kind.Data && { + enumValues: values.enumValues.map((value) => ({ + value: value.value, + datatypeIri: value.datatypeIri.trim() || undefined, + })), + unit: values.unit.trim() || undefined, + isIdentifier: values.isIdentifier, + }), +}); + +export const propertyPatch = ( + property: OntologyProperty, + request: CreateOntologyProperty +): Operation[] => { + const fields = [ + 'displayName', + 'description', + 'kind', + 'domain', + 'range', + 'inverse', + 'propertyChain', + 'disjointWith', + 'characteristics', + 'enumValues', + 'unit', + 'isIdentifier', + ]; + const changes = compare(pick(property, fields), pick(request, fields)); + + return property.version === undefined + ? changes + : [{ op: 'test', path: '/version', value: property.version }, ...changes]; +}; + +export const propertyFormFields = ( + t: ReturnType['t'], + kind: Kind, + editing: boolean, + disabled: boolean +): FieldProp[] => { + const iriField = ( + name: string, + label: string, + multiple = false + ): FieldProp => ({ + name, + label, + type: multiple ? FieldTypes.TEXTAREA : FieldTypes.TEXT, + placeholder: multiple ? t('message.ontology-iri-per-line') : undefined, + rules: { + validate: (value: string) => + (multiple + ? iriLines(value).every(isAbsoluteOntologyIri) + : !value.trim() || isAbsoluteOntologyIri(value.trim())) || + t('message.invalid-concept-iri'), + }, + }); + const fields: FieldProp[] = [ + { + name: 'name', + label: t('label.name'), + type: FieldTypes.TEXT, + required: true, + props: { isDisabled: editing }, + rules: { + validate: (value: string) => + Boolean(value.trim()) || + t('label.field-required', { field: t('label.name') }), + pattern: { + value: ENTITY_NAME_REGEX, + message: t('message.entity-name-validation'), + }, + }, + }, + { + name: 'displayName', + label: t('label.display-name'), + type: FieldTypes.TEXT, + }, + { + name: 'description', + label: t('label.description'), + type: FieldTypes.TEXTAREA, + }, + { + name: 'kind', + label: t('label.type'), + type: FieldTypes.SELECT, + required: true, + props: { + isDisabled: editing, + options: Object.values(Kind).map((id) => ({ id, label: id })), + }, + }, + { + ...iriField('iri', t('label.property-iri')), + props: { isDisabled: editing }, + }, + iriField('domain', t('label.property-domain'), true), + iriField('range', t('label.range'), true), + ]; + if (kind !== Kind.Annotation) { + fields.push( + { + name: 'characteristics', + label: t('label.characteristic-plural'), + type: FieldTypes.MULTI_SELECT, + props: { + items: (kind === Kind.Data + ? [Characteristic.Functional] + : Object.values(Characteristic) + ).map((id) => ({ id, label: id })), + }, + }, + iriField('disjointWith', t('label.disjoint-properties'), true) + ); + } + if (kind === Kind.Object) { + fields.push( + iriField('inverse', t('label.inverse')), + iriField('propertyChain', t('label.property-chain'), true) + ); + } + if (kind === Kind.Data) { + fields.push( + { name: 'unit', label: t('label.unit'), type: FieldTypes.TEXT }, + { + name: 'isIdentifier', + label: t('label.identifier'), + type: FieldTypes.CHECKBOX, + } + ); + } + + return fields.map((field) => ({ + ...field, + props: { ...field.props, isDisabled: disabled || field.props?.isDisabled }, + })); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPublicationWorkspace/OntologyPublicationWorkspace.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPublicationWorkspace/OntologyPublicationWorkspace.tsx new file mode 100644 index 000000000000..f6f12ee0f83b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPublicationWorkspace/OntologyPublicationWorkspace.tsx @@ -0,0 +1,39 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Box } from '@openmetadata/ui-core-components'; +import { ResourceEntity } from '../../../../enums/permissions.enum'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { useEntityPermissions } from '../../../../hooks/useEntityPermissions/useEntityPermissions'; +import OntologyReleasePanel from '../OntologyReleasePanel/OntologyReleasePanel'; + +const OntologyPublicationWorkspace = ({ ontology }: { ontology: Ontology }) => { + const { canEditAll } = useEntityPermissions( + ResourceEntity.ONTOLOGY, + { id: ontology?.id ?? '' }, + { enabled: Boolean(ontology), deleted: ontology?.deleted } + ); + + return ( + + {ontology && ( + + )} + + ); +}; + +export default OntologyPublicationWorkspace; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleaseDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleaseDetails.tsx new file mode 100644 index 000000000000..e979ff896faf --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleaseDetails.tsx @@ -0,0 +1,157 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Alert, + Box, + Button, + Dropdown, + Typography, +} from '@openmetadata/ui-core-components'; +import { ChevronDown } from '@openmetadata/ui-core-components/icons'; +import { isAxiosError } from 'axios'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { + EntityStatus, + OntologyRelease, +} from '../../../../generated/entity/data/ontologyRelease'; +import { exportOntologyRelease } from '../../../../rest/ontologyAPI'; +import { + EXPORT_FORMAT_TO_ACCEPT_HEADER, + EXPORT_FORMAT_TO_FILE_EXTENSION, + OntologyExportFormat, +} from '../../../../rest/rdfAPI'; +import { getEntityName } from '../../../../utils/EntityNameUtils'; +import { downloadFile } from '../../../../utils/Export/ExportUtils'; +import { showErrorToast } from '../../../../utils/ToastUtils'; +import OntologyReviewRelease from '../OntologyReviewPanel/OntologyReviewRelease'; + +interface OntologyReleaseDetailsProps { + release: OntologyRelease; + isActive: boolean; + canPublish: boolean; + onRequestApproval: () => Promise; + onActivate: () => Promise; +} + +const OntologyReleaseDetails = ({ + release, + isActive, + canPublish, + onRequestApproval, + onActivate, +}: OntologyReleaseDetailsProps) => { + const { t } = useTranslation(); + const [isExporting, setIsExporting] = useState(false); + const isApproved = release.entityStatus === EntityStatus.Approved; + const canRequestReview = release.entityStatus === EntityStatus.Draft; + + const exportRelease = async (format: OntologyExportFormat) => { + setIsExporting(true); + try { + const content = await exportOntologyRelease(release.id, format); + downloadFile( + content, + `ontology-release-${release.id}.${EXPORT_FORMAT_TO_FILE_EXTENSION[format]}`, + EXPORT_FORMAT_TO_ACCEPT_HEADER[format] + ); + } catch (error) { + showErrorToast( + isAxiosError(error) ? error : t('server.unexpected-error') + ); + } finally { + setIsExporting(false); + } + }; + + return ( + + + {getEntityName(release)} + + + {t(`label.${release.entityStatus.toLowerCase().replaceAll(' ', '-')}`)} + + {isActive && ( + + {t('label.active')} + + )} + + {t('message.ontology-consistency-not-run')} + + + + + + + void exportRelease('turtle')}> + {t('label.turtle-ttl')} + + void exportRelease('rdfxml')}> + {t('label.rdf-xml-rdf')} + + void exportRelease('ntriples')}> + {t('label.n-triples-nt')} + + void exportRelease('jsonld')}> + {t('label.json-ld')} + + + + + {release.reviewTask && ( + + {t('label.approval')} + + )} + {canRequestReview && ( + + )} + + + + + ); +}; + +export default OntologyReleaseDetails; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleasePanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleasePanel.test.tsx new file mode 100644 index 000000000000..02228d7140bf --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleasePanel.test.tsx @@ -0,0 +1,444 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { AxiosError } from 'axios'; +import { MemoryRouter } from 'react-router-dom'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { + EncodingVersion, + EntityStatus, + LogicalCheck, + OntologyRelease, +} from '../../../../generated/entity/data/ontologyRelease'; +import { OntologyState } from '../../../../generated/type/ontologyState'; +import APIClient from '../../../../rest/axiosClient'; +import { + renderWithQueryClient, + runQueryNotificationsSynchronously, +} from '../../../../test/unit/test-utils'; +import { showErrorToast } from '../../../../utils/ToastUtils'; +import OntologyReleasePanel from './OntologyReleasePanel'; + +jest.mock('../../../../rest/axiosClient', () => ({ + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), +})); +jest.mock('../../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +const get = APIClient.get as jest.MockedFunction; +const post = APIClient.post as jest.MockedFunction; +const put = APIClient.put as jest.MockedFunction; +const ontology: Ontology = { + id: 'ontology-id', + name: 'Sales', + fullyQualifiedName: 'Sales', + iri: 'urn:sales', + namespace: 'urn:sales:', +}; +const release: OntologyRelease = { + id: 'release-id', + name: 'Release 1', + fullyQualifiedName: 'Sales.Release1', + ontology: { id: ontology.id, type: 'ontology' }, + entityStatus: EntityStatus.Draft, + releaseRevision: 1, + sourceWorkingRevision: 7, + sealedAt: 1, + sealedBy: 'author', + memberCount: 1, + totalBytes: 100, + ontologyIri: ontology.iri, + versionIri: 'urn:sales:v1', + contentDigest: 'a'.repeat(64), + encodingVersion: EncodingVersion.OntologyReleaseV1, + logicalCheck: LogicalCheck.NotRun, +}; +const initialState: OntologyState = { + ontologyId: ontology.id, + activationRevision: 3, + workingRevision: 7, + lastReleaseRevision: 1, +}; +let currentState: OntologyState; +let currentRelease: OntologyRelease; +let currentOntology: Ontology; +const originalCreateObjectURL = URL.createObjectURL; +const originalRevokeObjectURL = URL.revokeObjectURL; +let downloadedBlob: Blob | undefined; +let downloadedFileName: string | undefined; +let anchorClick: jest.SpyInstance; + +const renderPanel = async (canEdit = true) => { + await act(async () => { + renderWithQueryClient( + + + + ); + }); +}; + +const selectRelease = async () => { + fireEvent.click( + screen.getByRole('button', { name: 'label.ontology-release' }) + ); + fireEvent.click(await screen.findByTestId('tree-node-release-id')); +}; + +runQueryNotificationsSynchronously(); + +describe('OntologyReleasePanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + downloadedBlob = undefined; + downloadedFileName = undefined; + URL.createObjectURL = jest.fn((blob) => { + if (blob instanceof Blob) { + downloadedBlob = blob; + } + + return 'blob:release-export'; + }); + URL.revokeObjectURL = jest.fn(); + anchorClick = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(function (this: HTMLAnchorElement) { + downloadedFileName = this.download; + }); + currentState = { ...initialState }; + currentRelease = { ...release }; + currentOntology = { ...ontology }; + get.mockImplementation(async (url) => { + if (url === '/ontologies/ontology-id') { + return { data: currentOntology }; + } + if (url.endsWith('/state')) { + return { data: currentState }; + } + if (url.endsWith('/diff')) { + return { data: { data: [], paging: { total: 0 } } }; + } + if (url.endsWith('/export')) { + return { data: 'sealed release content' }; + } + if (url === '/ontologyReleases/release-id') { + return { data: currentRelease }; + } + if (url === '/ontologyReleases/new-release-id') { + return { + data: { + ...release, + id: 'new-release-id', + name: 'Release 2', + releaseRevision: 2, + entityStatus: EntityStatus.Approved, + }, + }; + } + if (url === '/ontologyReleases') { + return { data: { data: [currentRelease], paging: { total: 1 } } }; + } + + throw new Error(`Unexpected request: ${url}`); + }); + }); + + afterEach(() => { + anchorClick.mockRestore(); + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + }); + + it.each([ + ['turtle', 'label.turtle-ttl', 'ttl', 'text/turtle'], + ['rdfxml', 'label.rdf-xml-rdf', 'rdf', 'application/rdf+xml'], + ['ntriples', 'label.n-triples-nt', 'nt', 'application/n-triples'], + ['jsonld', 'label.json-ld', 'jsonld', 'application/ld+json'], + ])( + 'downloads the selected sealed release as %s for a reader', + async (format, label, extension, mimeType) => { + await renderPanel(false); + await selectRelease(); + fireEvent.click( + await screen.findByRole('button', { name: 'label.export' }) + ); + fireEvent.click(await screen.findByRole('menuitem', { name: label })); + + await waitFor(() => expect(downloadedBlob).toBeInstanceOf(Blob)); + + expect(get).toHaveBeenCalledWith('/ontologyReleases/release-id/export', { + params: { format }, + headers: { Accept: mimeType }, + responseType: 'text', + }); + expect(downloadedFileName).toBe( + `ontology-release-release-id.${extension}` + ); + expect(downloadedBlob?.type).toBe(mimeType); + + const blob = downloadedBlob; + if (!blob) { + throw new Error('No release file was downloaded'); + } + const content = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.readAsText(blob); + }); + + expect(content).toBe('sealed release content'); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:release-export'); + expect(post).not.toHaveBeenCalled(); + expect(put).not.toHaveBeenCalled(); + } + ); + + it('reports a denied export without downloading and permits retry', async () => { + const requests = get.getMockImplementation(); + if (!requests) { + throw new Error('Missing API fixture'); + } + const denied = new AxiosError( + 'An imported definition is not readable', + '403' + ); + get.mockImplementation((url, config) => + url.endsWith('/export') ? Promise.reject(denied) : requests(url, config) + ); + await renderPanel(); + await selectRelease(); + fireEvent.click( + await screen.findByRole('button', { name: 'label.export' }) + ); + fireEvent.click( + await screen.findByRole('menuitem', { name: 'label.turtle-ttl' }) + ); + await waitFor(() => expect(showErrorToast).toHaveBeenCalledWith(denied)); + + expect(downloadedBlob).toBeUndefined(); + expect(screen.getByRole('button', { name: 'label.export' })).toBeEnabled(); + + get.mockImplementation(requests); + fireEvent.click(screen.getByRole('button', { name: 'label.export' })); + fireEvent.click( + await screen.findByRole('menuitem', { name: 'label.turtle-ttl' }) + ); + await waitFor(() => + expect(downloadedFileName).toBe('ontology-release-release-id.ttl') + ); + }); + + it('captures the displayed working revision without approving or activating it', async () => { + post.mockResolvedValue({ data: release }); + await renderPanel(); + await waitFor(() => + expect( + screen.getByRole('button', { name: 'label.capture-release' }) + ).toBeEnabled() + ); + await act(async () => + fireEvent.click( + screen.getByRole('button', { name: 'label.capture-release' }) + ) + ); + + await screen.findByTestId('ontology-release-definition'); + + expect(post).toHaveBeenCalledWith('/ontologies/ontology-id/releases', { + expectedWorkingRevision: 7, + }); + expect(put).not.toHaveBeenCalled(); + expect( + screen.getByRole('button', { name: 'label.activate-release' }) + ).toBeDisabled(); + expect( + screen.getByText('message.ontology-consistency-not-run') + ).toBeInTheDocument(); + }); + + it('submits the whole sealed release through the existing Tasks workflow', async () => { + post.mockResolvedValue({ + data: { + id: 'approval-task', + category: 'Approval', + type: 'RequestApproval', + name: 'Review release', + status: 'Open', + }, + }); + await renderPanel(); + await selectRelease(); + const request = await screen.findByRole('button', { + name: 'label.request-approval', + }); + await act(async () => fireEvent.click(request)); + + await waitFor(() => + expect(post).toHaveBeenCalledWith( + '/tasks', + expect.objectContaining({ + category: 'Approval', + type: 'RequestApproval', + about: '<#E::ontologyRelease::Sales.Release1>', + }) + ) + ); + + expect(put).not.toHaveBeenCalled(); + }); + + it('does not activate an already active release again', async () => { + currentState.activeReleaseId = release.id; + currentRelease.entityStatus = EntityStatus.Approved; + currentRelease.id = 'release-id'; + put.mockImplementation(async () => { + currentState = { ...currentState, activationRevision: 4 }; + + return { data: currentState }; + }); + await renderPanel(); + await screen.findByTestId('ontology-release-definition'); + + expect( + screen.getByRole('button', { name: 'label.activate-release' }) + ).toBeDisabled(); + expect( + screen.queryByRole('button', { name: 'label.request-approval' }) + ).not.toBeInTheDocument(); + expect(put).not.toHaveBeenCalled(); + }); + + it('retains a rejected decision and requires a new capture for another review', async () => { + currentRelease = { + ...release, + entityStatus: EntityStatus.Rejected, + reviewTask: 'rejected-task', + }; + await renderPanel(); + await selectRelease(); + + expect( + await screen.findByRole('link', { name: 'label.approval' }) + ).toHaveAttribute('href', '/tasks/rejected-task'); + expect( + screen.queryByRole('button', { name: 'label.request-approval' }) + ).not.toBeInTheDocument(); + expect(post).not.toHaveBeenCalled(); + }); + + it('links to the pending workflow without allowing a second submission', async () => { + currentRelease = { + ...release, + entityStatus: EntityStatus.InReview, + reviewTask: 'pending-task', + }; + await renderPanel(); + await selectRelease(); + await screen.findByRole('link', { name: 'label.approval' }); + + expect( + screen.queryByRole('button', { name: 'label.request-approval' }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'label.activate-release' }) + ).toBeDisabled(); + }); + + it('rolls back to an older approved release using the displayed activation revision', async () => { + currentState.activeReleaseId = 'new-release-id'; + currentRelease.entityStatus = EntityStatus.Approved; + put.mockImplementation(async () => { + currentState = { + ...currentState, + activeReleaseId: release.id, + activationRevision: 4, + }; + + return { data: currentState }; + }); + await renderPanel(); + await screen.findByTestId('ontology-release-definition'); + fireEvent.click( + screen.getByRole('button', { name: 'label.ontology-release' }) + ); + fireEvent.click(await screen.findByTestId('tree-node-release-id')); + await waitFor(() => + expect( + screen.getByRole('button', { name: 'label.activate-release' }) + ).toBeEnabled() + ); + await act(async () => + fireEvent.click( + screen.getByRole('button', { name: 'label.activate-release' }) + ) + ); + + expect(put).toHaveBeenCalledWith('/ontologies/ontology-id/activation', { + releaseId: 'release-id', + expectedActivationRevision: 3, + }); + expect( + await screen.findByTestId('ontology-active-release') + ).toBeInTheDocument(); + }); + + it('keeps the active pointer unchanged when the server rejects a stale activation', async () => { + currentState.activeReleaseId = 'new-release-id'; + currentRelease.entityStatus = EntityStatus.Approved; + const conflict = new AxiosError('Activation revision changed', '409'); + put.mockRejectedValue(conflict); + await renderPanel(); + await screen.findByTestId('ontology-release-definition'); + fireEvent.click( + screen.getByRole('button', { name: 'label.ontology-release' }) + ); + fireEvent.click(await screen.findByTestId('tree-node-release-id')); + await waitFor(() => + expect( + screen.getByRole('button', { name: 'label.activate-release' }) + ).toBeEnabled() + ); + await act(async () => + fireEvent.click( + screen.getByRole('button', { name: 'label.activate-release' }) + ) + ); + + expect(put).toHaveBeenCalledTimes(1); + expect(showErrorToast).toHaveBeenCalledWith(conflict); + expect( + screen.queryByTestId('ontology-active-release') + ).not.toBeInTheDocument(); + }); + + it('keeps release reads available without publication permission', async () => { + await renderPanel(false); + await selectRelease(); + await screen.findByTestId('ontology-release-definition'); + + expect( + screen.getByRole('button', { name: 'label.capture-release' }) + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'label.request-approval' }) + ).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'label.activate-release' }) + ).toBeDisabled(); + expect(post).not.toHaveBeenCalled(); + expect(put).not.toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleasePanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleasePanel.tsx new file mode 100644 index 000000000000..61d03e24ffaa --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleasePanel.tsx @@ -0,0 +1,119 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Alert, + Box, + Button, + Typography, +} from '@openmetadata/ui-core-components'; +import { useTranslation } from 'react-i18next'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { useOntologyPublication } from '../../../../hooks/governance/ontology/useOntologyPublication'; +import OntologyReleasePicker from '../OntologyReleasePicker/OntologyReleasePicker'; +import OntologyReleaseDetails from './OntologyReleaseDetails'; + +interface OntologyReleasePanelProps { + ontology: Ontology; + canEdit: boolean; +} + +const OntologyReleasePanel = ({ + ontology, + canEdit, +}: OntologyReleasePanelProps) => { + const { t } = useTranslation(); + const { + working, + selected, + releaseId, + release, + setSelectedId, + isBusy, + refresh, + capture, + requestApproval, + activate, + } = useOntologyPublication(ontology.id, canEdit); + const canPublish = canEdit && !isBusy && working.isSuccess; + + const renderRelease = () => { + if (selected.isError) { + return ( + + {t('message.ontology-review-unavailable')} + + ); + } + if (releaseId && selected.isPending) { + return {t('label.loading')}; + } + + return ( + release && ( + + ) + ); + }; + + return ( + + + + {t('label.ontology-release')} + + + + {working.isError && ( + + {t('message.ontology-review-unavailable')} + + )} + {working.isPending && ( + {t('label.loading')} + )} + {working.data && ( + + {t('label.draft')} · {t('label.version')}{' '} + {working.data.state.workingRevision} + + )} + + + setSelectedId(value?.id)} + /> + + {renderRelease()} + + ); +}; + +export default OntologyReleasePanel; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePicker/OntologyReleasePicker.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePicker/OntologyReleasePicker.tsx new file mode 100644 index 000000000000..4fcd43e0f641 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePicker/OntologyReleasePicker.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Button, + TreeSelect, + TreeSelectDataFetcher, +} from '@openmetadata/ui-core-components'; +import { isAxiosError } from 'axios'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { OntologyRelease } from '../../../../generated/entity/data/ontologyRelease'; +import { listOntologyReleases } from '../../../../rest/ontologyAPI'; +import { getEntityName } from '../../../../utils/EntityNameUtils'; +import { showErrorToast } from '../../../../utils/ToastUtils'; + +interface OntologyReleasePickerProps { + ontologyId: string; + label: string; + value?: OntologyRelease; + onChange: (release?: OntologyRelease) => void; +} + +const OntologyReleasePicker = ({ + ontologyId, + label, + value, + onChange, +}: OntologyReleasePickerProps) => { + const { t } = useTranslation(); + const fetchData = useCallback>( + async ({ after, pageSize = 25, signal }) => { + const page = await listOntologyReleases( + ontologyId, + { limit: pageSize, after }, + signal + ); + + return { + nodes: page.data.map((release) => ({ + id: release.id, + value: release.id, + data: release, + label: `${getEntityName(release)} (${t( + `label.${release.entityStatus.toLowerCase().replaceAll(' ', '-')}` + )})`, + isLeaf: true, + })), + total: page.paging.total, + nextCursor: page.paging.after, + hasMore: Boolean(page.paging.after), + }; + }, + [ontologyId, t] + ); + + return ( + ( + + )} + value={ + value + ? { + id: value.id, + value: value.id, + label: getEntityName(value), + data: value, + } + : null + } + onChange={(node) => + onChange((Array.isArray(node) ? node[0] : node)?.data) + } + onFetchError={(error) => + showErrorToast( + isAxiosError(error) + ? error + : t('server.entity-fetch-error', { + entity: t('label.ontology-release'), + }) + ) + } + /> + ); +}; + +export default OntologyReleasePicker; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewDefinition.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewDefinition.tsx new file mode 100644 index 000000000000..37063c8dd5ba --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewDefinition.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Typography } from '@openmetadata/ui-core-components'; +import { lazy, Suspense, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { OntologyReleaseMember } from '../../../../generated/type/ontologyReleaseMember'; +import type { OntologyReviewDefinition as ReviewDefinition } from '../../../../rest/ontologyAPI'; + +const CodeEditor = lazy( + () => import('../../../Database/SchemaEditor/CodeEditor') +); + +interface OntologyReviewDefinitionProps { + definition: ReviewDefinition | OntologyReleaseMember['payload']; +} + +const OntologyReviewDefinition = ({ + definition, +}: OntologyReviewDefinitionProps) => { + const { t } = useTranslation(); + const content = useMemo( + () => JSON.stringify(definition, null, 2), + [definition] + ); + + return ( + {t('label.loading')}}> + + + ); +}; + +export default OntologyReviewDefinition; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewPanel.test.tsx new file mode 100644 index 000000000000..a61484db457f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewPanel.test.tsx @@ -0,0 +1,198 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fireEvent, screen } from '@testing-library/react'; +import { + Task, + TaskCategory, + TaskStatus, + TaskType, +} from '../../../../generated/entity/tasks/task'; +import { OntologyReviewTarget } from '../../../../generated/type/ontologyReviewTarget'; +import APIClient from '../../../../rest/axiosClient'; +import { + renderWithQueryClient, + runQueryNotificationsSynchronously, +} from '../../../../test/unit/test-utils'; +import OntologyReviewPanel from './OntologyReviewPanel'; + +jest.mock('../../../../rest/axiosClient', () => ({ get: jest.fn() })); +jest.mock('@uiw/react-codemirror', () => { + const { forwardRef } = jest.requireActual('react'); + + return { + __esModule: true, + default: forwardRef(function Editor( + { value }, + ref + ) { + return ( +
    +          {value}
    +        
    + ); + }), + }; +}); + +const get = APIClient.get as jest.MockedFunction; +const target: OntologyReviewTarget = { + entityId: 'release-id', + entityVersion: 0.2, + ontologyId: 'ontology-id', + contentDigest: 'a'.repeat(64), + author: 'author', +}; +const task: Task = { + id: 'task-id', + category: TaskCategory.Approval, + status: TaskStatus.Open, + createdBy: { id: 'author-id', type: 'user' }, + name: 'Review release', + type: TaskType.RequestApproval, + about: { id: 'release-id', type: 'ontologyRelease' }, + ontologyReview: target, +}; +const release = { + id: 'release-id', + name: 'Release 2', + version: 0.2, + baseReleaseId: 'previous-release', +}; + +runQueryNotificationsSynchronously(); + +describe('OntologyReviewPanel', () => { + beforeEach(() => { + get.mockReset(); + }); + + it.each(['CHANGED', 'REMOVED'])( + 'compares the captured baseline and opens the %s member snapshots', + async (changeType) => { + get.mockImplementation(async (url) => { + if (url.endsWith('/versions/0.2')) { + return { data: release }; + } + if (url.endsWith('/diff')) { + return { + data: { + data: [ + { + entity: { + id: 'concept-id', + name: 'Customer', + type: 'ontologyConcept', + }, + memberKind: 'CONCEPT', + changeType, + beforeReleaseId: 'previous-release', + afterReleaseId: + changeType === 'CHANGED' ? 'release-id' : undefined, + }, + ], + paging: { total: 1 }, + }, + }; + } + if (url.includes('/members/CONCEPT/concept-id')) { + return { + data: { + payload: { + description: url.includes('/previous-release/') + ? 'Previous meaning' + : 'Submitted meaning', + }, + }, + }; + } + + throw new Error(`Unexpected request ${url}`); + }); + const onReadyChange = jest.fn(); + renderWithQueryClient( + + ); + fireEvent.click(await screen.findByRole('button', { name: 'Customer' })); + + expect(await screen.findByText(/Previous meaning/)).toBeInTheDocument(); + + if (changeType === 'CHANGED') { + expect( + await screen.findByText(/Submitted meaning/) + ).toBeInTheDocument(); + } else { + expect(screen.queryByText(/Submitted meaning/)).not.toBeInTheDocument(); + } + + expect(onReadyChange).toHaveBeenLastCalledWith(true); + expect(get).toHaveBeenCalledWith( + '/ontologyReleases/release-id/versions/0.2', + expect.anything() + ); + expect(get).toHaveBeenCalledWith( + '/ontologyReleases/release-id/diff', + expect.objectContaining({ + params: { + baseReleaseId: 'previous-release', + limit: 25, + after: undefined, + }, + }) + ); + } + ); + + it.each([403, 404])( + 'keeps a %s history error visible without substituting current content', + async (status) => { + get.mockRejectedValue({ response: { status } }); + const onReadyChange = jest.fn(); + renderWithQueryClient( + + ); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'message.ontology-review-unavailable' + ); + expect(screen.queryByTestId('review-content')).not.toBeInTheDocument(); + expect(get).toHaveBeenCalledTimes(1); + expect(onReadyChange).toHaveBeenLastCalledWith(false); + } + ); + + it('rejects history from a different identity or version', async () => { + get.mockResolvedValue({ data: { ...release, version: 0.3 } }); + renderWithQueryClient(); + + expect(await screen.findByRole('alert')).toBeInTheDocument(); + expect(screen.queryByTestId('review-content')).not.toBeInTheDocument(); + }); + + it('does not read an unpinned or per-definition task target', async () => { + const result = renderWithQueryClient( + + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + + result.rerender( + + ); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(get).not.toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewPanel.tsx new file mode 100644 index 000000000000..2e8bb59e97d7 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewPanel.tsx @@ -0,0 +1,114 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Alert, + Box, + Button, + Typography, +} from '@openmetadata/ui-core-components'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Task } from '../../../../generated/entity/tasks/task'; +import { getOntologyReleaseVersion } from '../../../../rest/ontologyAPI'; +import { getEntityName } from '../../../../utils/EntityNameUtils'; +import OntologyReviewRelease from './OntologyReviewRelease'; + +interface OntologyReviewPanelProps { + task: Task; + onReadyChange?: (ready: boolean) => void; +} + +const OntologyReviewPanel = ({ + task, + onReadyChange, +}: OntologyReviewPanelProps) => { + const { t } = useTranslation(); + const target = task.ontologyReview; + const entityType = task.about?.type; + const valid = Boolean( + target && + entityType === 'ontologyRelease' && + target.entityId === task.about?.id + ); + const { data, isPending, isError, refetch } = useQuery({ + queryKey: [ + 'ontology-review-definition', + entityType, + target?.entityId, + target?.entityVersion, + ], + enabled: valid, + retry: false, + gcTime: 0, + queryFn: ({ signal }) => { + if (!target || !entityType) { + throw new Error('Missing ontology review target'); + } + + return getOntologyReleaseVersion( + target.entityId, + target.entityVersion, + signal + ); + }, + }); + + useEffect(() => { + if (!valid || isPending || isError) { + onReadyChange?.(false); + } + + return () => onReadyChange?.(false); + }, [valid, isPending, isError, onReadyChange]); + + if (!valid || isError) { + return ( + + + {t('message.ontology-review-unavailable')} + {valid && ( + + )} + + + ); + } + + if (isPending || !data) { + return {t('label.loading')}; + } + + return ( + + + {t('label.submitted-definition')} + + + {getEntityName(data)} ·{' '} + {t('label.version-number', { version: target?.entityVersion })} + + + + ); +}; + +export default OntologyReviewPanel; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewRelease.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewRelease.tsx new file mode 100644 index 000000000000..f150bb9d1e34 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewRelease.tsx @@ -0,0 +1,181 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Alert, + Box, + Button, + Typography, +} from '@openmetadata/ui-core-components'; +import { useQueries, useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { OntologyReleaseDiff } from '../../../../generated/api/data/ontologyReleaseDiff'; +import { + getOntologyReleaseDiff, + getOntologyReleaseMember, +} from '../../../../rest/ontologyAPI'; +import { getEntityName } from '../../../../utils/EntityNameUtils'; +import OntologyReviewDefinition from './OntologyReviewDefinition'; + +interface OntologyReviewReleaseProps { + baseReleaseId?: string; + releaseId: string; + onReadyChange?: (ready: boolean) => void; +} + +const OntologyReviewRelease = ({ + baseReleaseId, + releaseId, + onReadyChange, +}: OntologyReviewReleaseProps) => { + const { t } = useTranslation(); + const [cursors, setCursors] = useState<(string | undefined)[]>([undefined]); + const [selected, setSelected] = + useState(); + const after = cursors[cursors.length - 1]; + const page = useQuery({ + queryKey: ['ontology-review-members', releaseId, baseReleaseId, after], + retry: false, + gcTime: 0, + queryFn: ({ signal }) => + getOntologyReleaseDiff( + releaseId, + { baseReleaseId, limit: 25, after }, + signal + ), + }); + const snapshots = selected + ? [ + { releaseId: selected.beforeReleaseId, label: 'label.before' }, + { releaseId: selected.afterReleaseId, label: 'label.after' }, + ].filter((snapshot) => snapshot.releaseId) + : []; + const members = useQueries({ + queries: snapshots.map((snapshot) => ({ + queryKey: [ + 'ontology-review-member', + snapshot.releaseId, + selected?.memberKind, + selected?.entity.id, + ], + retry: false, + gcTime: 0, + queryFn: ({ signal }: { signal: AbortSignal }) => { + if (!selected || !snapshot.releaseId) { + throw new Error('Missing captured ontology member'); + } + + return getOntologyReleaseMember( + snapshot.releaseId, + selected.memberKind, + selected.entity.id, + signal + ); + }, + })), + }); + + const ready = + page.isSuccess && + !page.isFetching && + members.every((member) => member.isSuccess && !member.isFetching); + useEffect(() => { + onReadyChange?.(ready); + + return () => onReadyChange?.(false); + }, [ready, onReadyChange]); + + if (page.isError) { + return ( + {t('message.ontology-review-unavailable')} + ); + } + + const renderMember = () => { + if (!selected) { + return null; + } + if (members.some((member) => member.isError)) { + return ( + + {t('message.ontology-review-unavailable')} + + ); + } + if (members.some((member) => member.isPending)) { + return {t('label.loading')}; + } + + return members.map( + (member, index) => + member.data && ( + + {t(snapshots[index].label)} + + + ) + ); + }; + + return ( + + {page.isPending ? ( + {t('label.loading')} + ) : ( +
      + {page.data.data.map((change) => ( +
    • + + + {t(`label.${change.changeType.toLowerCase()}`)} + +
    • + ))} +
    + )} + + + + + {renderMember()} +
    + ); +}; + +export default OntologyReviewRelease; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyStudioWorkspace/OntologyStudioWorkspace.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyStudioWorkspace/OntologyStudioWorkspace.tsx new file mode 100644 index 000000000000..37bb32a2d043 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyStudioWorkspace/OntologyStudioWorkspace.tsx @@ -0,0 +1,155 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Box, Tabs } from '@openmetadata/ui-core-components'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ResourceEntity } from '../../../../enums/permissions.enum'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { useApplicationStore } from '../../../../hooks/useApplicationStore'; +import { useEntityPermissions } from '../../../../hooks/useEntityPermissions/useEntityPermissions'; +import OntologyMemoryReviewPanel from '../../../OntologyExplorer/OntologyMemoryReviewPanel'; +import OntologyStudioQueryConsole from '../../../OntologyExplorer/OntologyStudioQueryConsole'; +import OntologyAuthoringWorkspace from '../OntologyAuthoringWorkspace/OntologyAuthoringWorkspace'; +import OntologyAxiomWorkspace from '../OntologyAxiomWorkspace/OntologyAxiomWorkspace'; +import OntologyCreateForm from '../OntologyCreateForm/OntologyCreateForm'; +import OntologyGraphView from '../OntologyGraphView/OntologyGraphView'; +import OntologyPublicationWorkspace from '../OntologyPublicationWorkspace/OntologyPublicationWorkspace'; + +interface OntologyStudioWorkspaceProps { + ontology?: Ontology; + canCreate: boolean; + initialDraftId?: string; + onSaved: (ontology: Ontology) => void; +} + +const OntologyStudioWorkspace = ({ + ontology, + canCreate, + initialDraftId, + onSaved, +}: OntologyStudioWorkspaceProps) => { + const { t } = useTranslation(); + const { currentUser } = useApplicationStore(); + const [surface, setSurface] = useState(initialDraftId ? 'review' : 'graph'); + const [definition, setDefinition] = useState('concept'); + const [revision, setRevision] = useState(0); + const { canEditAll } = useEntityPermissions( + ResourceEntity.ONTOLOGY, + { id: ontology?.id ?? '' }, + { enabled: Boolean(ontology), deleted: ontology?.deleted } + ); + + if (!ontology) { + return ( + + + + ); + } + + const renderModel = () => ( + <> + setDefinition(String(key))}> + + {['ontology', 'concept', 'property', 'axiom'].map((id) => ( + + {t(`label.${id === 'axiom' ? 'constraint-plural' : id}`)} + + ))} + + + {definition === 'ontology' && ( + + )} + {(definition === 'concept' || definition === 'property') && ( + + )} + {definition === 'axiom' && } + + ); + const renderContent = () => { + switch (surface) { + case 'graph': + return ; + case 'releases': + return ; + case 'query': + return ( + + ); + case 'review': + return ( + setRevision((value) => value + 1)} + /> + ); + default: + return renderModel(); + } + }; + + return ( + + setSurface(String(key))}> + + {Object.entries({ + graph: 'graph', + model: 'model', + releases: 'ontology-release', + review: 'needs-review', + query: 'query', + }).map(([id, label]) => ( + + {t(`label.${label}`)} + + ))} + + + + {renderContent()} + + + ); +}; + +export default OntologyStudioWorkspace; diff --git a/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts b/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts index 205cbbe980fd..2ed3e84ad4e6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts +++ b/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts @@ -21,6 +21,10 @@ export enum EntityType { DATABASE = 'database', DATABASE_SCHEMA = 'databaseSchema', GLOSSARY = 'glossary', + ONTOLOGY = 'ontology', + ONTOLOGY_CONCEPT = 'ontologyConcept', + ONTOLOGY_PROPERTY = 'ontologyProperty', + ONTOLOGY_AXIOM = 'ontologyAxiom', GLOSSARY_TERM = 'glossaryTerm', DATABASE_SERVICE = 'databaseService', MESSAGING_SERVICE = 'messagingService', diff --git a/openmetadata-ui/src/main/resources/ui/src/enums/permissions.enum.ts b/openmetadata-ui/src/main/resources/ui/src/enums/permissions.enum.ts index e7b22c8570c0..dc423310f2ed 100644 --- a/openmetadata-ui/src/main/resources/ui/src/enums/permissions.enum.ts +++ b/openmetadata-ui/src/main/resources/ui/src/enums/permissions.enum.ts @@ -79,4 +79,9 @@ export enum ResourceEntity { TASK = 'task', CONTEXT_MEMORY = 'contextMemory', ONTOLOGY_CHANGE_SET = 'ontologyChangeSet', + ONTOLOGY = 'ontology', + ONTOLOGY_CONCEPT = 'ontologyConcept', + ONTOLOGY_PROPERTY = 'ontologyProperty', + ONTOLOGY_AXIOM = 'ontologyAxiom', + ONTOLOGY_RELEASE = 'ontologyRelease', } diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/activateOntologyRelease.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/activateOntologyRelease.ts new file mode 100644 index 000000000000..7547748a1194 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/activateOntologyRelease.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Activate an approved immutable ontology release. Selecting an older release performs an + * audited rollback. + */ +export interface ActivateOntologyRelease { + expectedActivationRevision: number; + releaseId: string; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/captureOntologyRelease.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/captureOntologyRelease.ts new file mode 100644 index 000000000000..9c3ffa0e6d92 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/captureOntologyRelease.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Capture one committed working revision as an immutable ontology release candidate. + */ +export interface CaptureOntologyRelease { + expectedWorkingRevision: number; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntology.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntology.ts new file mode 100644 index 000000000000..9c9d3387e1f5 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntology.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Create or update an independent ontology. Approval is managed through existing approval + * workflows. + */ +export interface CreateOntology { + description?: string; + displayName?: string; + iri: string; + layer?: Layer; + name: string; + namespace: string; + owners?: EntityReference[]; + prefixes?: Prefix[]; + provider?: ProviderType; + reviewers?: EntityReference[]; +} + +/** + * Governance layer of an ontology model. + */ +export enum Layer { + L1 = "L1", + L2 = "L2", + L3 = "L3", +} + +/** + * This schema defines the EntityReferenceList type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * A compact IRI prefix and its absolute namespace. + */ +export interface Prefix { + namespace: string; + prefix: string; +} + +/** + * Type of provider of an entity. Some entities are provided by the `system`. Some are + * entities created and provided by the `user`. Typically `system` provide entities can't be + * deleted and can only be disabled. Some apps such as AutoPilot create entities with + * `automation` provider type. These entities can be deleted by the user. + */ +export enum ProviderType { + Automation = "automation", + System = "system", + User = "user", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyAxiom.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyAxiom.ts index 921b130156b8..0d79ee98d018 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyAxiom.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyAxiom.ts @@ -14,24 +14,19 @@ * Create or update a governed OWL axiom. */ export interface CreateOntologyAxiom { - axiomType: AxiomType; - description: string; - displayName: string; - /** - * Lifecycle stage of the axiom. When omitted, the axiom starts in Draft. - */ - entityStatus?: EntityStatus; - expressions?: OntologyExpression[]; - glossary: string; - literal?: Literal; - name: string; - owners?: EntityReference[]; - propertyIri?: string; - provenance: Provenance; - provider?: ProviderType; - reviewers?: EntityReference[]; - subjectIri: string; - targetIri?: string; + axiomType: AxiomType; + description: string; + displayName: string; + expressions?: OntologyExpression[]; + literal?: Literal; + name: string; + ontology: string; + owners?: EntityReference[]; + propertyIri?: string; + provenance: Provenance; + provider?: ProviderType; + subjectIri: string; + targetIri?: string; } export enum AxiomType { @@ -43,24 +38,6 @@ export enum AxiomType { SubclassOf = "SUBCLASS_OF", } -/** - * Lifecycle stage of the axiom. When omitted, the axiom starts in Draft. - * - * General lifecycle stage for entities that reference this vocabulary. Specialized entities - * can declare their own status schema and lifecycle policy. Entity types without an - * entityStatus property have no lifecycle. When a create request omits the stage, the - * server assigns the entity type's initial stage. - */ -export enum EntityStatus { - Approved = "Approved", - Archived = "Archived", - Deprecated = "Deprecated", - Draft = "Draft", - InReview = "In Review", - Rejected = "Rejected", - Unprocessed = "Unprocessed", -} - /** * A recursive OWL class expression represented without untyped maps. * diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyChangeSet.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyChangeSet.ts index 5401f6071705..d81397eaab17 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyChangeSet.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyChangeSet.ts @@ -14,13 +14,21 @@ * Create or update an ontology authoring session. */ export interface CreateOntologyChangeSet { - description: string; - displayName: string; + /** + * Expected native working revision when applying this proposal. + */ + baseWorkingRevision?: number; + description: string; + displayName: string; /** * Fully qualified names of affected ontology models. */ - glossaries: string[]; + glossaries?: string[]; name: string; + /** + * Fully qualified native ontology name. Mutually exclusive with glossaries. + */ + ontology?: string; operations?: OntologyChangeOperation[]; owners?: EntityReference[]; provider?: ProviderType; @@ -38,6 +46,7 @@ export interface OntologyChangeOperation { * Version used for optimistic concurrency. Omitted for create operations. */ baseVersion?: number; + concept?: OntologyConcept; /** * Model confidence in this AI suggestion, from 0 to 1. */ @@ -50,6 +59,7 @@ export interface OntologyChangeOperation { */ modelId?: string; operationType: OperationType; + property?: OntologyProperty; /** * Model explanation of the evidence behind this AI suggestion. */ @@ -138,6 +148,8 @@ export enum DataType { * example, a table has an attribute called database of type EntityReference that captures * the relationship of a table `belongs to a` database. * + * Owning ontology entity. Glossaries do not own ontology definitions. + * * Reference to the data contract for this entity. * * Glossary that this term belongs to. @@ -204,13 +216,11 @@ export interface OntologyAxiom { deleted?: boolean; description: string; displayName: string; - entityStatus: EntityStatus; /** * Class expressions participating in the axiom, ordered from left to right. */ expressions: OntologyExpression[]; fullyQualifiedName: string; - glossary: EntityReference; href?: string; id: string; /** @@ -220,11 +230,11 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -306,28 +316,6 @@ export interface FieldChange { oldValue?: any; } -/** - * General lifecycle stage for entities that reference this vocabulary. Specialized entities - * can declare their own status schema and lifecycle policy. Entity types without an - * entityStatus property have no lifecycle. When a create request omits the stage, the - * server assigns the entity type's initial stage. - * - * Status of the Glossary. - * - * Approval status of the glossary term. - * - * Approval status of this relation edge. - */ -export enum EntityStatus { - Approved = "Approved", - Archived = "Archived", - Deprecated = "Deprecated", - Draft = "Draft", - InReview = "In Review", - Rejected = "Rejected", - Unprocessed = "Unprocessed", -} - /** * A recursive OWL class expression represented without untyped maps. * @@ -431,6 +419,101 @@ export enum ProviderType { User = "user", } +/** + * A formal class, SKOS concept, or both, owned by an ontology independently of glossary + * terms. + */ +export interface OntologyConcept { + changeDescription?: ChangeDescription; + conceptMappings?: ConceptMapping[]; + conceptType: OntologyConceptType; + deleted?: boolean; + description?: string; + displayName?: string; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + provider?: ProviderType; + /** + * Direct named subclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not + * stored or independently editable. + */ + subClasses?: string[]; + /** + * Direct named superclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. + * Not stored or independently editable. + */ + superClasses?: string[]; + synonyms?: string[]; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +/** + * Mapping from an ontology term to an external concept. + */ +export interface ConceptMapping { + /** + * External concept IRI to map this ontology term to. + */ + conceptIri: string; + /** + * Type of mapping used for the external concept alignment. + */ + mappingType: ConceptMappingType; + /** + * Optional external concept scheme IRI for the mapped concept. + */ + schemeIri?: string; + /** + * Optional source label or catalog for the external concept. + */ + source?: string; +} + +/** + * Type of mapping used for the external concept alignment. + * + * Type of mapping used to align this term with an external concept. + */ +export enum ConceptMappingType { + BroadMatch = "BROAD_MATCH", + CloseMatch = "CLOSE_MATCH", + ExactMatch = "EXACT_MATCH", + NarrowMatch = "NARROW_MATCH", + RelatedMatch = "RELATED_MATCH", + SameAs = "SAME_AS", +} + +/** + * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies + * round-trip with the typing they were authored with. + * + * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which + * emits the term as an `owl:Class` and a `skos:Concept`. + */ +export enum OntologyConceptType { + Both = "BOTH", + OwlClass = "OWL_CLASS", + SkosConcept = "SKOS_CONCEPT", +} + /** * This schema defines the Glossary entity. A Glossary is collection of hierarchical * GlossaryTerms. @@ -555,6 +638,28 @@ export interface Glossary { votes?: Votes; } +/** + * Status of the Glossary. + * + * General lifecycle stage for entities that reference this vocabulary. Specialized entities + * can declare their own status schema and lifecycle policy. Entity types without an + * entityStatus property have no lifecycle. When a create request omits the stage, the + * server assigns the entity type's initial stage. + * + * Approval status of the glossary term. + * + * Approval status of this relation edge. + */ +export enum EntityStatus { + Approved = "Approved", + Archived = "Archived", + Deprecated = "Deprecated", + Draft = "Draft", + InReview = "In Review", + Rejected = "Rejected", + Unprocessed = "Unprocessed", +} + export interface Namespace { /** * Full namespace IRI the prefix expands to (e.g., `http://example.com/ontology/hcp#`). @@ -863,56 +968,110 @@ export interface Votes { upVotes?: number; } -/** - * Mapping from an ontology term to an external concept. - */ -export interface ConceptMapping { - /** - * External concept IRI to map this ontology term to. - */ - conceptIri: string; - /** - * Type of mapping used for the external concept alignment. - */ - mappingType: ConceptMappingType; - /** - * Optional external concept scheme IRI for the mapped concept. - */ - schemeIri?: string; - /** - * Optional source label or catalog for the external concept. - */ - source?: string; -} - -/** - * Type of mapping used for the external concept alignment. - * - * Type of mapping used to align this term with an external concept. - */ -export enum ConceptMappingType { - BroadMatch = "BROAD_MATCH", - CloseMatch = "CLOSE_MATCH", - ExactMatch = "EXACT_MATCH", - NarrowMatch = "NARROW_MATCH", - RelatedMatch = "RELATED_MATCH", - SameAs = "SAME_AS", -} - export enum OperationType { AddRelationship = "ADD_RELATIONSHIP", CreateGlossary = "CREATE_GLOSSARY", CreateTerm = "CREATE_TERM", DeleteAttribute = "DELETE_ATTRIBUTE", DeleteAxiom = "DELETE_AXIOM", + DeleteConcept = "DELETE_CONCEPT", DeleteMapping = "DELETE_MAPPING", + DeleteProperty = "DELETE_PROPERTY", DeleteRelationship = "DELETE_RELATIONSHIP", DeleteTerm = "DELETE_TERM", UpdateRelationship = "UPDATE_RELATIONSHIP", UpdateTerm = "UPDATE_TERM", UpsertAttribute = "UPSERT_ATTRIBUTE", UpsertAxiom = "UPSERT_AXIOM", + UpsertConcept = "UPSERT_CONCEPT", UpsertMapping = "UPSERT_MAPPING", + UpsertProperty = "UPSERT_PROPERTY", +} + +/** + * A formal object, data, or annotation property owned by an ontology, independent of + * glossary relation types. + */ +export interface OntologyProperty { + changeDescription?: ChangeDescription; + characteristics?: Characteristic[]; + deleted?: boolean; + description?: string; + /** + * Semantic IRIs of properties disjoint with this property. + */ + disjointWith?: string[]; + displayName?: string; + /** + * Semantic IRIs of the classes in the property domain. + */ + domain?: string[]; + /** + * Allowed typed values for a DATA property, projected as an OWL data enumeration. An absent + * datatype denotes xsd:string. Empty means no enumeration constraint. + */ + enumValues?: Literal[]; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Semantic IRI of the inverse object property. + */ + inverse?: string; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + /** + * Identifier intent for a DATA property. This annotation does not assert functionality, an + * OWL key, or uniqueness in source data. + */ + isIdentifier?: boolean; + kind: Kind; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + /** + * Ordered object-property IRIs whose composition implies this property. + */ + propertyChain?: string[]; + provider?: ProviderType; + /** + * Semantic IRIs of range classes or datatypes. + */ + range?: string[]; + /** + * Unit IRI or display symbol for a DATA property, retained as an annotation without + * implying conversion. + */ + unit?: string; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +export enum Characteristic { + Asymmetric = "ASYMMETRIC", + Functional = "FUNCTIONAL", + InverseFunctional = "INVERSE_FUNCTIONAL", + Irreflexive = "IRREFLEXIVE", + Reflexive = "REFLEXIVE", + Symmetric = "SYMMETRIC", + Transitive = "TRANSITIVE", +} + +export enum Kind { + Annotation = "ANNOTATION", + Data = "DATA", + Object = "OBJECT", } /** @@ -1123,19 +1282,6 @@ export interface GlossaryTerm { votes?: Votes; } -/** - * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which - * emits the term as an `owl:Class` and a `skos:Concept`. - * - * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies - * round-trip with the typing they were authored with. - */ -export enum OntologyConceptType { - Both = "BOTH", - OwlClass = "OWL_CLASS", - SkosConcept = "SKOS_CONCEPT", -} - /** * Pinned source provenance when this term belongs to an application ontology subset. * diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyConcept.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyConcept.ts new file mode 100644 index 000000000000..9d1da6a3cdce --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyConcept.ts @@ -0,0 +1,147 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Create or update an independent ontologyConcept. Approval is managed through existing + * approval workflows. + */ +export interface CreateOntologyConcept { + conceptMappings?: ConceptMapping[]; + conceptType: OntologyConceptType; + description?: string; + displayName?: string; + /** + * Semantic IRI preserved for imported definitions. If omitted, creation mints an IRI from + * the ontology namespace and entity UUID; updates preserve the stored IRI. + */ + iri?: string; + name: string; + /** + * Fully qualified name of the owning ontology. + */ + ontology: string; + owners?: EntityReference[]; + provider?: ProviderType; + synonyms?: string[]; +} + +/** + * Mapping from an ontology term to an external concept. + */ +export interface ConceptMapping { + /** + * External concept IRI to map this ontology term to. + */ + conceptIri: string; + /** + * Type of mapping used for the external concept alignment. + */ + mappingType: ConceptMappingType; + /** + * Optional external concept scheme IRI for the mapped concept. + */ + schemeIri?: string; + /** + * Optional source label or catalog for the external concept. + */ + source?: string; +} + +/** + * Type of mapping used for the external concept alignment. + * + * Type of mapping used to align this term with an external concept. + */ +export enum ConceptMappingType { + BroadMatch = "BROAD_MATCH", + CloseMatch = "CLOSE_MATCH", + ExactMatch = "EXACT_MATCH", + NarrowMatch = "NARROW_MATCH", + RelatedMatch = "RELATED_MATCH", + SameAs = "SAME_AS", +} + +/** + * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies + * round-trip with the typing they were authored with. + */ +export enum OntologyConceptType { + Both = "BOTH", + OwlClass = "OWL_CLASS", + SkosConcept = "SKOS_CONCEPT", +} + +/** + * This schema defines the EntityReferenceList type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * Type of provider of an entity. Some entities are provided by the `system`. Some are + * entities created and provided by the `user`. Typically `system` provide entities can't be + * deleted and can only be disabled. Some apps such as AutoPilot create entities with + * `automation` provider type. These entities can be deleted by the user. + */ +export enum ProviderType { + Automation = "automation", + System = "system", + User = "user", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyProperty.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyProperty.ts new file mode 100644 index 000000000000..aeef598303c0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyProperty.ts @@ -0,0 +1,132 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Create or update an independent ontologyProperty. Approval is managed through existing + * approval workflows. + */ +export interface CreateOntologyProperty { + characteristics?: Characteristic[]; + description?: string; + disjointWith?: string[]; + displayName?: string; + domain?: string[]; + enumValues?: Literal[]; + inverse?: string; + /** + * Semantic IRI preserved for imported definitions. If omitted, creation mints an IRI from + * the ontology namespace and entity UUID; updates preserve the stored IRI. + */ + iri?: string; + isIdentifier?: boolean; + kind: Kind; + name: string; + /** + * Fully qualified name of the owning ontology. + */ + ontology: string; + owners?: EntityReference[]; + propertyChain?: string[]; + provider?: ProviderType; + range?: string[]; + unit?: string; +} + +export enum Characteristic { + Asymmetric = "ASYMMETRIC", + Functional = "FUNCTIONAL", + InverseFunctional = "INVERSE_FUNCTIONAL", + Irreflexive = "IRREFLEXIVE", + Reflexive = "REFLEXIVE", + Symmetric = "SYMMETRIC", + Transitive = "TRANSITIVE", +} + +/** + * A typed OWL literal. + */ +export interface Literal { + datatypeIri?: string; + value: string; +} + +export enum Kind { + Annotation = "ANNOTATION", + Data = "DATA", + Object = "OBJECT", +} + +/** + * This schema defines the EntityReferenceList type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * Type of provider of an entity. Some entities are provided by the `system`. Some are + * entities created and provided by the `user`. Typically `system` provide entities can't be + * deleted and can only be disabled. Some apps such as AutoPilot create entities with + * `automation` provider type. These entities can be deleted by the user. + */ +export enum ProviderType { + Automation = "automation", + System = "system", + User = "user", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyDomainDraftResult.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyDomainDraftResult.ts index 127b44572a75..c5b582ac682a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyDomainDraftResult.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyDomainDraftResult.ts @@ -23,13 +23,21 @@ export interface OntologyDomainDraftResult { * Create or update an ontology authoring session. */ export interface CreateOntologyChangeSetRequest { - description: string; - displayName: string; + /** + * Expected native working revision when applying this proposal. + */ + baseWorkingRevision?: number; + description: string; + displayName: string; /** * Fully qualified names of affected ontology models. */ - glossaries: string[]; + glossaries?: string[]; name: string; + /** + * Fully qualified native ontology name. Mutually exclusive with glossaries. + */ + ontology?: string; operations?: OntologyChangeOperation[]; owners?: EntityReference[]; provider?: ProviderType; @@ -47,6 +55,7 @@ export interface OntologyChangeOperation { * Version used for optimistic concurrency. Omitted for create operations. */ baseVersion?: number; + concept?: OntologyConcept; /** * Model confidence in this AI suggestion, from 0 to 1. */ @@ -59,6 +68,7 @@ export interface OntologyChangeOperation { */ modelId?: string; operationType: OperationType; + property?: OntologyProperty; /** * Model explanation of the evidence behind this AI suggestion. */ @@ -147,6 +157,8 @@ export enum DataType { * example, a table has an attribute called database of type EntityReference that captures * the relationship of a table `belongs to a` database. * + * Owning ontology entity. Glossaries do not own ontology definitions. + * * Reference to the data contract for this entity. * * Glossary that this term belongs to. @@ -213,13 +225,11 @@ export interface OntologyAxiom { deleted?: boolean; description: string; displayName: string; - entityStatus: EntityStatus; /** * Class expressions participating in the axiom, ordered from left to right. */ expressions: OntologyExpression[]; fullyQualifiedName: string; - glossary: EntityReference; href?: string; id: string; /** @@ -229,11 +239,11 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -315,28 +325,6 @@ export interface FieldChange { oldValue?: any; } -/** - * General lifecycle stage for entities that reference this vocabulary. Specialized entities - * can declare their own status schema and lifecycle policy. Entity types without an - * entityStatus property have no lifecycle. When a create request omits the stage, the - * server assigns the entity type's initial stage. - * - * Status of the Glossary. - * - * Approval status of the glossary term. - * - * Approval status of this relation edge. - */ -export enum EntityStatus { - Approved = "Approved", - Archived = "Archived", - Deprecated = "Deprecated", - Draft = "Draft", - InReview = "In Review", - Rejected = "Rejected", - Unprocessed = "Unprocessed", -} - /** * A recursive OWL class expression represented without untyped maps. * @@ -440,6 +428,101 @@ export enum ProviderType { User = "user", } +/** + * A formal class, SKOS concept, or both, owned by an ontology independently of glossary + * terms. + */ +export interface OntologyConcept { + changeDescription?: ChangeDescription; + conceptMappings?: ConceptMapping[]; + conceptType: OntologyConceptType; + deleted?: boolean; + description?: string; + displayName?: string; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + provider?: ProviderType; + /** + * Direct named subclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not + * stored or independently editable. + */ + subClasses?: string[]; + /** + * Direct named superclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. + * Not stored or independently editable. + */ + superClasses?: string[]; + synonyms?: string[]; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +/** + * Mapping from an ontology term to an external concept. + */ +export interface ConceptMapping { + /** + * External concept IRI to map this ontology term to. + */ + conceptIri: string; + /** + * Type of mapping used for the external concept alignment. + */ + mappingType: ConceptMappingType; + /** + * Optional external concept scheme IRI for the mapped concept. + */ + schemeIri?: string; + /** + * Optional source label or catalog for the external concept. + */ + source?: string; +} + +/** + * Type of mapping used for the external concept alignment. + * + * Type of mapping used to align this term with an external concept. + */ +export enum ConceptMappingType { + BroadMatch = "BROAD_MATCH", + CloseMatch = "CLOSE_MATCH", + ExactMatch = "EXACT_MATCH", + NarrowMatch = "NARROW_MATCH", + RelatedMatch = "RELATED_MATCH", + SameAs = "SAME_AS", +} + +/** + * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies + * round-trip with the typing they were authored with. + * + * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which + * emits the term as an `owl:Class` and a `skos:Concept`. + */ +export enum OntologyConceptType { + Both = "BOTH", + OwlClass = "OWL_CLASS", + SkosConcept = "SKOS_CONCEPT", +} + /** * This schema defines the Glossary entity. A Glossary is collection of hierarchical * GlossaryTerms. @@ -564,6 +647,28 @@ export interface Glossary { votes?: Votes; } +/** + * Status of the Glossary. + * + * General lifecycle stage for entities that reference this vocabulary. Specialized entities + * can declare their own status schema and lifecycle policy. Entity types without an + * entityStatus property have no lifecycle. When a create request omits the stage, the + * server assigns the entity type's initial stage. + * + * Approval status of the glossary term. + * + * Approval status of this relation edge. + */ +export enum EntityStatus { + Approved = "Approved", + Archived = "Archived", + Deprecated = "Deprecated", + Draft = "Draft", + InReview = "In Review", + Rejected = "Rejected", + Unprocessed = "Unprocessed", +} + export interface Namespace { /** * Full namespace IRI the prefix expands to (e.g., `http://example.com/ontology/hcp#`). @@ -872,56 +977,110 @@ export interface Votes { upVotes?: number; } -/** - * Mapping from an ontology term to an external concept. - */ -export interface ConceptMapping { - /** - * External concept IRI to map this ontology term to. - */ - conceptIri: string; - /** - * Type of mapping used for the external concept alignment. - */ - mappingType: ConceptMappingType; - /** - * Optional external concept scheme IRI for the mapped concept. - */ - schemeIri?: string; - /** - * Optional source label or catalog for the external concept. - */ - source?: string; -} - -/** - * Type of mapping used for the external concept alignment. - * - * Type of mapping used to align this term with an external concept. - */ -export enum ConceptMappingType { - BroadMatch = "BROAD_MATCH", - CloseMatch = "CLOSE_MATCH", - ExactMatch = "EXACT_MATCH", - NarrowMatch = "NARROW_MATCH", - RelatedMatch = "RELATED_MATCH", - SameAs = "SAME_AS", -} - export enum OperationType { AddRelationship = "ADD_RELATIONSHIP", CreateGlossary = "CREATE_GLOSSARY", CreateTerm = "CREATE_TERM", DeleteAttribute = "DELETE_ATTRIBUTE", DeleteAxiom = "DELETE_AXIOM", + DeleteConcept = "DELETE_CONCEPT", DeleteMapping = "DELETE_MAPPING", + DeleteProperty = "DELETE_PROPERTY", DeleteRelationship = "DELETE_RELATIONSHIP", DeleteTerm = "DELETE_TERM", UpdateRelationship = "UPDATE_RELATIONSHIP", UpdateTerm = "UPDATE_TERM", UpsertAttribute = "UPSERT_ATTRIBUTE", UpsertAxiom = "UPSERT_AXIOM", + UpsertConcept = "UPSERT_CONCEPT", UpsertMapping = "UPSERT_MAPPING", + UpsertProperty = "UPSERT_PROPERTY", +} + +/** + * A formal object, data, or annotation property owned by an ontology, independent of + * glossary relation types. + */ +export interface OntologyProperty { + changeDescription?: ChangeDescription; + characteristics?: Characteristic[]; + deleted?: boolean; + description?: string; + /** + * Semantic IRIs of properties disjoint with this property. + */ + disjointWith?: string[]; + displayName?: string; + /** + * Semantic IRIs of the classes in the property domain. + */ + domain?: string[]; + /** + * Allowed typed values for a DATA property, projected as an OWL data enumeration. An absent + * datatype denotes xsd:string. Empty means no enumeration constraint. + */ + enumValues?: Literal[]; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Semantic IRI of the inverse object property. + */ + inverse?: string; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + /** + * Identifier intent for a DATA property. This annotation does not assert functionality, an + * OWL key, or uniqueness in source data. + */ + isIdentifier?: boolean; + kind: Kind; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + /** + * Ordered object-property IRIs whose composition implies this property. + */ + propertyChain?: string[]; + provider?: ProviderType; + /** + * Semantic IRIs of range classes or datatypes. + */ + range?: string[]; + /** + * Unit IRI or display symbol for a DATA property, retained as an annotation without + * implying conversion. + */ + unit?: string; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +export enum Characteristic { + Asymmetric = "ASYMMETRIC", + Functional = "FUNCTIONAL", + InverseFunctional = "INVERSE_FUNCTIONAL", + Irreflexive = "IRREFLEXIVE", + Reflexive = "REFLEXIVE", + Symmetric = "SYMMETRIC", + Transitive = "TRANSITIVE", +} + +export enum Kind { + Annotation = "ANNOTATION", + Data = "DATA", + Object = "OBJECT", } /** @@ -1132,19 +1291,6 @@ export interface GlossaryTerm { votes?: Votes; } -/** - * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which - * emits the term as an `owl:Class` and a `skos:Concept`. - * - * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies - * round-trip with the typing they were authored with. - */ -export enum OntologyConceptType { - Both = "BOTH", - OwlClass = "OWL_CLASS", - SkosConcept = "SKOS_CONCEPT", -} - /** * Pinned source provenance when this term belongs to an application ontology subset. * diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyMappingSuggestion.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyMappingSuggestion.ts index 202bde4de7d7..d001bee27bad 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyMappingSuggestion.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyMappingSuggestion.ts @@ -31,6 +31,7 @@ export interface OntologyChangeOperation { * Version used for optimistic concurrency. Omitted for create operations. */ baseVersion?: number; + concept?: OntologyConcept; /** * Model confidence in this AI suggestion, from 0 to 1. */ @@ -43,6 +44,7 @@ export interface OntologyChangeOperation { */ modelId?: string; operationType: OperationType; + property?: OntologyProperty; /** * Model explanation of the evidence behind this AI suggestion. */ @@ -131,6 +133,8 @@ export enum DataType { * example, a table has an attribute called database of type EntityReference that captures * the relationship of a table `belongs to a` database. * + * Owning ontology entity. Glossaries do not own ontology definitions. + * * Reference to the data contract for this entity. * * Glossary that this term belongs to. @@ -197,13 +201,11 @@ export interface OntologyAxiom { deleted?: boolean; description: string; displayName: string; - entityStatus: EntityStatus; /** * Class expressions participating in the axiom, ordered from left to right. */ expressions: OntologyExpression[]; fullyQualifiedName: string; - glossary: EntityReference; href?: string; id: string; /** @@ -213,11 +215,11 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -299,28 +301,6 @@ export interface FieldChange { oldValue?: any; } -/** - * General lifecycle stage for entities that reference this vocabulary. Specialized entities - * can declare their own status schema and lifecycle policy. Entity types without an - * entityStatus property have no lifecycle. When a create request omits the stage, the - * server assigns the entity type's initial stage. - * - * Status of the Glossary. - * - * Approval status of the glossary term. - * - * Approval status of this relation edge. - */ -export enum EntityStatus { - Approved = "Approved", - Archived = "Archived", - Deprecated = "Deprecated", - Draft = "Draft", - InReview = "In Review", - Rejected = "Rejected", - Unprocessed = "Unprocessed", -} - /** * A recursive OWL class expression represented without untyped maps. * @@ -424,6 +404,101 @@ export enum ProviderType { User = "user", } +/** + * A formal class, SKOS concept, or both, owned by an ontology independently of glossary + * terms. + */ +export interface OntologyConcept { + changeDescription?: ChangeDescription; + conceptMappings?: ConceptMapping[]; + conceptType: OntologyConceptType; + deleted?: boolean; + description?: string; + displayName?: string; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + provider?: ProviderType; + /** + * Direct named subclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not + * stored or independently editable. + */ + subClasses?: string[]; + /** + * Direct named superclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. + * Not stored or independently editable. + */ + superClasses?: string[]; + synonyms?: string[]; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +/** + * Mapping from an ontology term to an external concept. + */ +export interface ConceptMapping { + /** + * External concept IRI to map this ontology term to. + */ + conceptIri: string; + /** + * Type of mapping used for the external concept alignment. + */ + mappingType: ConceptMappingType; + /** + * Optional external concept scheme IRI for the mapped concept. + */ + schemeIri?: string; + /** + * Optional source label or catalog for the external concept. + */ + source?: string; +} + +/** + * Type of mapping used for the external concept alignment. + * + * Type of mapping used to align this term with an external concept. + */ +export enum ConceptMappingType { + BroadMatch = "BROAD_MATCH", + CloseMatch = "CLOSE_MATCH", + ExactMatch = "EXACT_MATCH", + NarrowMatch = "NARROW_MATCH", + RelatedMatch = "RELATED_MATCH", + SameAs = "SAME_AS", +} + +/** + * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies + * round-trip with the typing they were authored with. + * + * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which + * emits the term as an `owl:Class` and a `skos:Concept`. + */ +export enum OntologyConceptType { + Both = "BOTH", + OwlClass = "OWL_CLASS", + SkosConcept = "SKOS_CONCEPT", +} + /** * This schema defines the Glossary entity. A Glossary is collection of hierarchical * GlossaryTerms. @@ -548,6 +623,28 @@ export interface Glossary { votes?: Votes; } +/** + * Status of the Glossary. + * + * General lifecycle stage for entities that reference this vocabulary. Specialized entities + * can declare their own status schema and lifecycle policy. Entity types without an + * entityStatus property have no lifecycle. When a create request omits the stage, the + * server assigns the entity type's initial stage. + * + * Approval status of the glossary term. + * + * Approval status of this relation edge. + */ +export enum EntityStatus { + Approved = "Approved", + Archived = "Archived", + Deprecated = "Deprecated", + Draft = "Draft", + InReview = "In Review", + Rejected = "Rejected", + Unprocessed = "Unprocessed", +} + export interface Namespace { /** * Full namespace IRI the prefix expands to (e.g., `http://example.com/ontology/hcp#`). @@ -856,56 +953,110 @@ export interface Votes { upVotes?: number; } -/** - * Mapping from an ontology term to an external concept. - */ -export interface ConceptMapping { - /** - * External concept IRI to map this ontology term to. - */ - conceptIri: string; - /** - * Type of mapping used for the external concept alignment. - */ - mappingType: ConceptMappingType; - /** - * Optional external concept scheme IRI for the mapped concept. - */ - schemeIri?: string; - /** - * Optional source label or catalog for the external concept. - */ - source?: string; -} - -/** - * Type of mapping used for the external concept alignment. - * - * Type of mapping used to align this term with an external concept. - */ -export enum ConceptMappingType { - BroadMatch = "BROAD_MATCH", - CloseMatch = "CLOSE_MATCH", - ExactMatch = "EXACT_MATCH", - NarrowMatch = "NARROW_MATCH", - RelatedMatch = "RELATED_MATCH", - SameAs = "SAME_AS", -} - export enum OperationType { AddRelationship = "ADD_RELATIONSHIP", CreateGlossary = "CREATE_GLOSSARY", CreateTerm = "CREATE_TERM", DeleteAttribute = "DELETE_ATTRIBUTE", DeleteAxiom = "DELETE_AXIOM", + DeleteConcept = "DELETE_CONCEPT", DeleteMapping = "DELETE_MAPPING", + DeleteProperty = "DELETE_PROPERTY", DeleteRelationship = "DELETE_RELATIONSHIP", DeleteTerm = "DELETE_TERM", UpdateRelationship = "UPDATE_RELATIONSHIP", UpdateTerm = "UPDATE_TERM", UpsertAttribute = "UPSERT_ATTRIBUTE", UpsertAxiom = "UPSERT_AXIOM", + UpsertConcept = "UPSERT_CONCEPT", UpsertMapping = "UPSERT_MAPPING", + UpsertProperty = "UPSERT_PROPERTY", +} + +/** + * A formal object, data, or annotation property owned by an ontology, independent of + * glossary relation types. + */ +export interface OntologyProperty { + changeDescription?: ChangeDescription; + characteristics?: Characteristic[]; + deleted?: boolean; + description?: string; + /** + * Semantic IRIs of properties disjoint with this property. + */ + disjointWith?: string[]; + displayName?: string; + /** + * Semantic IRIs of the classes in the property domain. + */ + domain?: string[]; + /** + * Allowed typed values for a DATA property, projected as an OWL data enumeration. An absent + * datatype denotes xsd:string. Empty means no enumeration constraint. + */ + enumValues?: Literal[]; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Semantic IRI of the inverse object property. + */ + inverse?: string; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + /** + * Identifier intent for a DATA property. This annotation does not assert functionality, an + * OWL key, or uniqueness in source data. + */ + isIdentifier?: boolean; + kind: Kind; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + /** + * Ordered object-property IRIs whose composition implies this property. + */ + propertyChain?: string[]; + provider?: ProviderType; + /** + * Semantic IRIs of range classes or datatypes. + */ + range?: string[]; + /** + * Unit IRI or display symbol for a DATA property, retained as an annotation without + * implying conversion. + */ + unit?: string; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +export enum Characteristic { + Asymmetric = "ASYMMETRIC", + Functional = "FUNCTIONAL", + InverseFunctional = "INVERSE_FUNCTIONAL", + Irreflexive = "IRREFLEXIVE", + Reflexive = "REFLEXIVE", + Symmetric = "SYMMETRIC", + Transitive = "TRANSITIVE", +} + +export enum Kind { + Annotation = "ANNOTATION", + Data = "DATA", + Object = "OBJECT", } /** @@ -1116,19 +1267,6 @@ export interface GlossaryTerm { votes?: Votes; } -/** - * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which - * emits the term as an `owl:Class` and a `skos:Concept`. - * - * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies - * round-trip with the typing they were authored with. - */ -export enum OntologyConceptType { - Both = "BOTH", - OwlClass = "OWL_CLASS", - SkosConcept = "SKOS_CONCEPT", -} - /** * Pinned source provenance when this term belongs to an application ontology subset. * diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyMappingSuggestionList.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyMappingSuggestionList.ts index 7dc029d36c25..8648619117a0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyMappingSuggestionList.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyMappingSuggestionList.ts @@ -40,6 +40,7 @@ export interface OntologyChangeOperation { * Version used for optimistic concurrency. Omitted for create operations. */ baseVersion?: number; + concept?: OntologyConcept; /** * Model confidence in this AI suggestion, from 0 to 1. */ @@ -52,6 +53,7 @@ export interface OntologyChangeOperation { */ modelId?: string; operationType: OperationType; + property?: OntologyProperty; /** * Model explanation of the evidence behind this AI suggestion. */ @@ -140,6 +142,8 @@ export enum DataType { * example, a table has an attribute called database of type EntityReference that captures * the relationship of a table `belongs to a` database. * + * Owning ontology entity. Glossaries do not own ontology definitions. + * * Reference to the data contract for this entity. * * Glossary that this term belongs to. @@ -206,13 +210,11 @@ export interface OntologyAxiom { deleted?: boolean; description: string; displayName: string; - entityStatus: EntityStatus; /** * Class expressions participating in the axiom, ordered from left to right. */ expressions: OntologyExpression[]; fullyQualifiedName: string; - glossary: EntityReference; href?: string; id: string; /** @@ -222,11 +224,11 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -308,28 +310,6 @@ export interface FieldChange { oldValue?: any; } -/** - * General lifecycle stage for entities that reference this vocabulary. Specialized entities - * can declare their own status schema and lifecycle policy. Entity types without an - * entityStatus property have no lifecycle. When a create request omits the stage, the - * server assigns the entity type's initial stage. - * - * Status of the Glossary. - * - * Approval status of the glossary term. - * - * Approval status of this relation edge. - */ -export enum EntityStatus { - Approved = "Approved", - Archived = "Archived", - Deprecated = "Deprecated", - Draft = "Draft", - InReview = "In Review", - Rejected = "Rejected", - Unprocessed = "Unprocessed", -} - /** * A recursive OWL class expression represented without untyped maps. * @@ -433,6 +413,101 @@ export enum ProviderType { User = "user", } +/** + * A formal class, SKOS concept, or both, owned by an ontology independently of glossary + * terms. + */ +export interface OntologyConcept { + changeDescription?: ChangeDescription; + conceptMappings?: ConceptMapping[]; + conceptType: OntologyConceptType; + deleted?: boolean; + description?: string; + displayName?: string; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + provider?: ProviderType; + /** + * Direct named subclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not + * stored or independently editable. + */ + subClasses?: string[]; + /** + * Direct named superclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. + * Not stored or independently editable. + */ + superClasses?: string[]; + synonyms?: string[]; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +/** + * Mapping from an ontology term to an external concept. + */ +export interface ConceptMapping { + /** + * External concept IRI to map this ontology term to. + */ + conceptIri: string; + /** + * Type of mapping used for the external concept alignment. + */ + mappingType: ConceptMappingType; + /** + * Optional external concept scheme IRI for the mapped concept. + */ + schemeIri?: string; + /** + * Optional source label or catalog for the external concept. + */ + source?: string; +} + +/** + * Type of mapping used for the external concept alignment. + * + * Type of mapping used to align this term with an external concept. + */ +export enum ConceptMappingType { + BroadMatch = "BROAD_MATCH", + CloseMatch = "CLOSE_MATCH", + ExactMatch = "EXACT_MATCH", + NarrowMatch = "NARROW_MATCH", + RelatedMatch = "RELATED_MATCH", + SameAs = "SAME_AS", +} + +/** + * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies + * round-trip with the typing they were authored with. + * + * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which + * emits the term as an `owl:Class` and a `skos:Concept`. + */ +export enum OntologyConceptType { + Both = "BOTH", + OwlClass = "OWL_CLASS", + SkosConcept = "SKOS_CONCEPT", +} + /** * This schema defines the Glossary entity. A Glossary is collection of hierarchical * GlossaryTerms. @@ -557,6 +632,28 @@ export interface Glossary { votes?: Votes; } +/** + * Status of the Glossary. + * + * General lifecycle stage for entities that reference this vocabulary. Specialized entities + * can declare their own status schema and lifecycle policy. Entity types without an + * entityStatus property have no lifecycle. When a create request omits the stage, the + * server assigns the entity type's initial stage. + * + * Approval status of the glossary term. + * + * Approval status of this relation edge. + */ +export enum EntityStatus { + Approved = "Approved", + Archived = "Archived", + Deprecated = "Deprecated", + Draft = "Draft", + InReview = "In Review", + Rejected = "Rejected", + Unprocessed = "Unprocessed", +} + export interface Namespace { /** * Full namespace IRI the prefix expands to (e.g., `http://example.com/ontology/hcp#`). @@ -865,56 +962,110 @@ export interface Votes { upVotes?: number; } -/** - * Mapping from an ontology term to an external concept. - */ -export interface ConceptMapping { - /** - * External concept IRI to map this ontology term to. - */ - conceptIri: string; - /** - * Type of mapping used for the external concept alignment. - */ - mappingType: ConceptMappingType; - /** - * Optional external concept scheme IRI for the mapped concept. - */ - schemeIri?: string; - /** - * Optional source label or catalog for the external concept. - */ - source?: string; -} - -/** - * Type of mapping used for the external concept alignment. - * - * Type of mapping used to align this term with an external concept. - */ -export enum ConceptMappingType { - BroadMatch = "BROAD_MATCH", - CloseMatch = "CLOSE_MATCH", - ExactMatch = "EXACT_MATCH", - NarrowMatch = "NARROW_MATCH", - RelatedMatch = "RELATED_MATCH", - SameAs = "SAME_AS", -} - export enum OperationType { AddRelationship = "ADD_RELATIONSHIP", CreateGlossary = "CREATE_GLOSSARY", CreateTerm = "CREATE_TERM", DeleteAttribute = "DELETE_ATTRIBUTE", DeleteAxiom = "DELETE_AXIOM", + DeleteConcept = "DELETE_CONCEPT", DeleteMapping = "DELETE_MAPPING", + DeleteProperty = "DELETE_PROPERTY", DeleteRelationship = "DELETE_RELATIONSHIP", DeleteTerm = "DELETE_TERM", UpdateRelationship = "UPDATE_RELATIONSHIP", UpdateTerm = "UPDATE_TERM", UpsertAttribute = "UPSERT_ATTRIBUTE", UpsertAxiom = "UPSERT_AXIOM", + UpsertConcept = "UPSERT_CONCEPT", UpsertMapping = "UPSERT_MAPPING", + UpsertProperty = "UPSERT_PROPERTY", +} + +/** + * A formal object, data, or annotation property owned by an ontology, independent of + * glossary relation types. + */ +export interface OntologyProperty { + changeDescription?: ChangeDescription; + characteristics?: Characteristic[]; + deleted?: boolean; + description?: string; + /** + * Semantic IRIs of properties disjoint with this property. + */ + disjointWith?: string[]; + displayName?: string; + /** + * Semantic IRIs of the classes in the property domain. + */ + domain?: string[]; + /** + * Allowed typed values for a DATA property, projected as an OWL data enumeration. An absent + * datatype denotes xsd:string. Empty means no enumeration constraint. + */ + enumValues?: Literal[]; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Semantic IRI of the inverse object property. + */ + inverse?: string; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + /** + * Identifier intent for a DATA property. This annotation does not assert functionality, an + * OWL key, or uniqueness in source data. + */ + isIdentifier?: boolean; + kind: Kind; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + /** + * Ordered object-property IRIs whose composition implies this property. + */ + propertyChain?: string[]; + provider?: ProviderType; + /** + * Semantic IRIs of range classes or datatypes. + */ + range?: string[]; + /** + * Unit IRI or display symbol for a DATA property, retained as an annotation without + * implying conversion. + */ + unit?: string; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +export enum Characteristic { + Asymmetric = "ASYMMETRIC", + Functional = "FUNCTIONAL", + InverseFunctional = "INVERSE_FUNCTIONAL", + Irreflexive = "IRREFLEXIVE", + Reflexive = "REFLEXIVE", + Symmetric = "SYMMETRIC", + Transitive = "TRANSITIVE", +} + +export enum Kind { + Annotation = "ANNOTATION", + Data = "DATA", + Object = "OBJECT", } /** @@ -1125,19 +1276,6 @@ export interface GlossaryTerm { votes?: Votes; } -/** - * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which - * emits the term as an `owl:Class` and a `skos:Concept`. - * - * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies - * round-trip with the typing they were authored with. - */ -export enum OntologyConceptType { - Both = "BOTH", - OwlClass = "OWL_CLASS", - SkosConcept = "SKOS_CONCEPT", -} - /** * Pinned source provenance when this term belongs to an application ontology subset. * diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyProfileReport.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyProfileReport.ts index 50697e999059..119ba1c52e09 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyProfileReport.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyProfileReport.ts @@ -31,11 +31,16 @@ export interface OntologyProfileViolation { export enum Code { IllegalPunning = "ILLEGAL_PUNNING", InvalidAxiomShape = "INVALID_AXIOM_SHAPE", + InvalidClassKind = "INVALID_CLASS_KIND", InvalidExpressionShape = "INVALID_EXPRESSION_SHAPE", NonSimpleCardinality = "NON_SIMPLE_CARDINALITY", + NonSimpleProperty = "NON_SIMPLE_PROPERTY", PropertyChainRegularity = "PROPERTY_CHAIN_REGULARITY", ReservedSubjectIRI = "RESERVED_SUBJECT_IRI", + SubclassCycle = "SUBCLASS_CYCLE", TransitiveCardinality = "TRANSITIVE_CARDINALITY", + UndeclaredClass = "UNDECLARED_CLASS", + UndeclaredProperty = "UNDECLARED_PROPERTY", } export enum Severity { diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyRelationshipSuggestion.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyRelationshipSuggestion.ts index 0123e2bc4a4d..3a1afa38c241 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyRelationshipSuggestion.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyRelationshipSuggestion.ts @@ -30,6 +30,7 @@ export interface OntologyChangeOperation { * Version used for optimistic concurrency. Omitted for create operations. */ baseVersion?: number; + concept?: OntologyConcept; /** * Model confidence in this AI suggestion, from 0 to 1. */ @@ -42,6 +43,7 @@ export interface OntologyChangeOperation { */ modelId?: string; operationType: OperationType; + property?: OntologyProperty; /** * Model explanation of the evidence behind this AI suggestion. */ @@ -130,6 +132,8 @@ export enum DataType { * example, a table has an attribute called database of type EntityReference that captures * the relationship of a table `belongs to a` database. * + * Owning ontology entity. Glossaries do not own ontology definitions. + * * Reference to the data contract for this entity. * * Glossary that this term belongs to. @@ -196,13 +200,11 @@ export interface OntologyAxiom { deleted?: boolean; description: string; displayName: string; - entityStatus: EntityStatus; /** * Class expressions participating in the axiom, ordered from left to right. */ expressions: OntologyExpression[]; fullyQualifiedName: string; - glossary: EntityReference; href?: string; id: string; /** @@ -212,11 +214,11 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -298,28 +300,6 @@ export interface FieldChange { oldValue?: any; } -/** - * General lifecycle stage for entities that reference this vocabulary. Specialized entities - * can declare their own status schema and lifecycle policy. Entity types without an - * entityStatus property have no lifecycle. When a create request omits the stage, the - * server assigns the entity type's initial stage. - * - * Status of the Glossary. - * - * Approval status of the glossary term. - * - * Approval status of this relation edge. - */ -export enum EntityStatus { - Approved = "Approved", - Archived = "Archived", - Deprecated = "Deprecated", - Draft = "Draft", - InReview = "In Review", - Rejected = "Rejected", - Unprocessed = "Unprocessed", -} - /** * A recursive OWL class expression represented without untyped maps. * @@ -423,6 +403,101 @@ export enum ProviderType { User = "user", } +/** + * A formal class, SKOS concept, or both, owned by an ontology independently of glossary + * terms. + */ +export interface OntologyConcept { + changeDescription?: ChangeDescription; + conceptMappings?: ConceptMapping[]; + conceptType: OntologyConceptType; + deleted?: boolean; + description?: string; + displayName?: string; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + provider?: ProviderType; + /** + * Direct named subclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not + * stored or independently editable. + */ + subClasses?: string[]; + /** + * Direct named superclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. + * Not stored or independently editable. + */ + superClasses?: string[]; + synonyms?: string[]; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +/** + * Mapping from an ontology term to an external concept. + */ +export interface ConceptMapping { + /** + * External concept IRI to map this ontology term to. + */ + conceptIri: string; + /** + * Type of mapping used for the external concept alignment. + */ + mappingType: ConceptMappingType; + /** + * Optional external concept scheme IRI for the mapped concept. + */ + schemeIri?: string; + /** + * Optional source label or catalog for the external concept. + */ + source?: string; +} + +/** + * Type of mapping used for the external concept alignment. + * + * Type of mapping used to align this term with an external concept. + */ +export enum ConceptMappingType { + BroadMatch = "BROAD_MATCH", + CloseMatch = "CLOSE_MATCH", + ExactMatch = "EXACT_MATCH", + NarrowMatch = "NARROW_MATCH", + RelatedMatch = "RELATED_MATCH", + SameAs = "SAME_AS", +} + +/** + * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies + * round-trip with the typing they were authored with. + * + * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which + * emits the term as an `owl:Class` and a `skos:Concept`. + */ +export enum OntologyConceptType { + Both = "BOTH", + OwlClass = "OWL_CLASS", + SkosConcept = "SKOS_CONCEPT", +} + /** * This schema defines the Glossary entity. A Glossary is collection of hierarchical * GlossaryTerms. @@ -547,6 +622,28 @@ export interface Glossary { votes?: Votes; } +/** + * Status of the Glossary. + * + * General lifecycle stage for entities that reference this vocabulary. Specialized entities + * can declare their own status schema and lifecycle policy. Entity types without an + * entityStatus property have no lifecycle. When a create request omits the stage, the + * server assigns the entity type's initial stage. + * + * Approval status of the glossary term. + * + * Approval status of this relation edge. + */ +export enum EntityStatus { + Approved = "Approved", + Archived = "Archived", + Deprecated = "Deprecated", + Draft = "Draft", + InReview = "In Review", + Rejected = "Rejected", + Unprocessed = "Unprocessed", +} + export interface Namespace { /** * Full namespace IRI the prefix expands to (e.g., `http://example.com/ontology/hcp#`). @@ -855,56 +952,110 @@ export interface Votes { upVotes?: number; } -/** - * Mapping from an ontology term to an external concept. - */ -export interface ConceptMapping { - /** - * External concept IRI to map this ontology term to. - */ - conceptIri: string; - /** - * Type of mapping used for the external concept alignment. - */ - mappingType: ConceptMappingType; - /** - * Optional external concept scheme IRI for the mapped concept. - */ - schemeIri?: string; - /** - * Optional source label or catalog for the external concept. - */ - source?: string; -} - -/** - * Type of mapping used for the external concept alignment. - * - * Type of mapping used to align this term with an external concept. - */ -export enum ConceptMappingType { - BroadMatch = "BROAD_MATCH", - CloseMatch = "CLOSE_MATCH", - ExactMatch = "EXACT_MATCH", - NarrowMatch = "NARROW_MATCH", - RelatedMatch = "RELATED_MATCH", - SameAs = "SAME_AS", -} - export enum OperationType { AddRelationship = "ADD_RELATIONSHIP", CreateGlossary = "CREATE_GLOSSARY", CreateTerm = "CREATE_TERM", DeleteAttribute = "DELETE_ATTRIBUTE", DeleteAxiom = "DELETE_AXIOM", + DeleteConcept = "DELETE_CONCEPT", DeleteMapping = "DELETE_MAPPING", + DeleteProperty = "DELETE_PROPERTY", DeleteRelationship = "DELETE_RELATIONSHIP", DeleteTerm = "DELETE_TERM", UpdateRelationship = "UPDATE_RELATIONSHIP", UpdateTerm = "UPDATE_TERM", UpsertAttribute = "UPSERT_ATTRIBUTE", UpsertAxiom = "UPSERT_AXIOM", + UpsertConcept = "UPSERT_CONCEPT", UpsertMapping = "UPSERT_MAPPING", + UpsertProperty = "UPSERT_PROPERTY", +} + +/** + * A formal object, data, or annotation property owned by an ontology, independent of + * glossary relation types. + */ +export interface OntologyProperty { + changeDescription?: ChangeDescription; + characteristics?: Characteristic[]; + deleted?: boolean; + description?: string; + /** + * Semantic IRIs of properties disjoint with this property. + */ + disjointWith?: string[]; + displayName?: string; + /** + * Semantic IRIs of the classes in the property domain. + */ + domain?: string[]; + /** + * Allowed typed values for a DATA property, projected as an OWL data enumeration. An absent + * datatype denotes xsd:string. Empty means no enumeration constraint. + */ + enumValues?: Literal[]; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Semantic IRI of the inverse object property. + */ + inverse?: string; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + /** + * Identifier intent for a DATA property. This annotation does not assert functionality, an + * OWL key, or uniqueness in source data. + */ + isIdentifier?: boolean; + kind: Kind; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + /** + * Ordered object-property IRIs whose composition implies this property. + */ + propertyChain?: string[]; + provider?: ProviderType; + /** + * Semantic IRIs of range classes or datatypes. + */ + range?: string[]; + /** + * Unit IRI or display symbol for a DATA property, retained as an annotation without + * implying conversion. + */ + unit?: string; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +export enum Characteristic { + Asymmetric = "ASYMMETRIC", + Functional = "FUNCTIONAL", + InverseFunctional = "INVERSE_FUNCTIONAL", + Irreflexive = "IRREFLEXIVE", + Reflexive = "REFLEXIVE", + Symmetric = "SYMMETRIC", + Transitive = "TRANSITIVE", +} + +export enum Kind { + Annotation = "ANNOTATION", + Data = "DATA", + Object = "OBJECT", } /** @@ -1115,19 +1266,6 @@ export interface GlossaryTerm { votes?: Votes; } -/** - * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which - * emits the term as an `owl:Class` and a `skos:Concept`. - * - * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies - * round-trip with the typing they were authored with. - */ -export enum OntologyConceptType { - Both = "BOTH", - OwlClass = "OWL_CLASS", - SkosConcept = "SKOS_CONCEPT", -} - /** * Pinned source provenance when this term belongs to an application ontology subset. * diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyRelationshipSuggestionList.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyRelationshipSuggestionList.ts index 95c56d23e33b..9f1bac56dbc0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyRelationshipSuggestionList.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyRelationshipSuggestionList.ts @@ -39,6 +39,7 @@ export interface OntologyChangeOperation { * Version used for optimistic concurrency. Omitted for create operations. */ baseVersion?: number; + concept?: OntologyConcept; /** * Model confidence in this AI suggestion, from 0 to 1. */ @@ -51,6 +52,7 @@ export interface OntologyChangeOperation { */ modelId?: string; operationType: OperationType; + property?: OntologyProperty; /** * Model explanation of the evidence behind this AI suggestion. */ @@ -139,6 +141,8 @@ export enum DataType { * example, a table has an attribute called database of type EntityReference that captures * the relationship of a table `belongs to a` database. * + * Owning ontology entity. Glossaries do not own ontology definitions. + * * Reference to the data contract for this entity. * * Glossary that this term belongs to. @@ -205,13 +209,11 @@ export interface OntologyAxiom { deleted?: boolean; description: string; displayName: string; - entityStatus: EntityStatus; /** * Class expressions participating in the axiom, ordered from left to right. */ expressions: OntologyExpression[]; fullyQualifiedName: string; - glossary: EntityReference; href?: string; id: string; /** @@ -221,11 +223,11 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -307,28 +309,6 @@ export interface FieldChange { oldValue?: any; } -/** - * General lifecycle stage for entities that reference this vocabulary. Specialized entities - * can declare their own status schema and lifecycle policy. Entity types without an - * entityStatus property have no lifecycle. When a create request omits the stage, the - * server assigns the entity type's initial stage. - * - * Status of the Glossary. - * - * Approval status of the glossary term. - * - * Approval status of this relation edge. - */ -export enum EntityStatus { - Approved = "Approved", - Archived = "Archived", - Deprecated = "Deprecated", - Draft = "Draft", - InReview = "In Review", - Rejected = "Rejected", - Unprocessed = "Unprocessed", -} - /** * A recursive OWL class expression represented without untyped maps. * @@ -432,6 +412,101 @@ export enum ProviderType { User = "user", } +/** + * A formal class, SKOS concept, or both, owned by an ontology independently of glossary + * terms. + */ +export interface OntologyConcept { + changeDescription?: ChangeDescription; + conceptMappings?: ConceptMapping[]; + conceptType: OntologyConceptType; + deleted?: boolean; + description?: string; + displayName?: string; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + provider?: ProviderType; + /** + * Direct named subclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not + * stored or independently editable. + */ + subClasses?: string[]; + /** + * Direct named superclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. + * Not stored or independently editable. + */ + superClasses?: string[]; + synonyms?: string[]; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +/** + * Mapping from an ontology term to an external concept. + */ +export interface ConceptMapping { + /** + * External concept IRI to map this ontology term to. + */ + conceptIri: string; + /** + * Type of mapping used for the external concept alignment. + */ + mappingType: ConceptMappingType; + /** + * Optional external concept scheme IRI for the mapped concept. + */ + schemeIri?: string; + /** + * Optional source label or catalog for the external concept. + */ + source?: string; +} + +/** + * Type of mapping used for the external concept alignment. + * + * Type of mapping used to align this term with an external concept. + */ +export enum ConceptMappingType { + BroadMatch = "BROAD_MATCH", + CloseMatch = "CLOSE_MATCH", + ExactMatch = "EXACT_MATCH", + NarrowMatch = "NARROW_MATCH", + RelatedMatch = "RELATED_MATCH", + SameAs = "SAME_AS", +} + +/** + * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies + * round-trip with the typing they were authored with. + * + * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which + * emits the term as an `owl:Class` and a `skos:Concept`. + */ +export enum OntologyConceptType { + Both = "BOTH", + OwlClass = "OWL_CLASS", + SkosConcept = "SKOS_CONCEPT", +} + /** * This schema defines the Glossary entity. A Glossary is collection of hierarchical * GlossaryTerms. @@ -556,6 +631,28 @@ export interface Glossary { votes?: Votes; } +/** + * Status of the Glossary. + * + * General lifecycle stage for entities that reference this vocabulary. Specialized entities + * can declare their own status schema and lifecycle policy. Entity types without an + * entityStatus property have no lifecycle. When a create request omits the stage, the + * server assigns the entity type's initial stage. + * + * Approval status of the glossary term. + * + * Approval status of this relation edge. + */ +export enum EntityStatus { + Approved = "Approved", + Archived = "Archived", + Deprecated = "Deprecated", + Draft = "Draft", + InReview = "In Review", + Rejected = "Rejected", + Unprocessed = "Unprocessed", +} + export interface Namespace { /** * Full namespace IRI the prefix expands to (e.g., `http://example.com/ontology/hcp#`). @@ -864,56 +961,110 @@ export interface Votes { upVotes?: number; } -/** - * Mapping from an ontology term to an external concept. - */ -export interface ConceptMapping { - /** - * External concept IRI to map this ontology term to. - */ - conceptIri: string; - /** - * Type of mapping used for the external concept alignment. - */ - mappingType: ConceptMappingType; - /** - * Optional external concept scheme IRI for the mapped concept. - */ - schemeIri?: string; - /** - * Optional source label or catalog for the external concept. - */ - source?: string; -} - -/** - * Type of mapping used for the external concept alignment. - * - * Type of mapping used to align this term with an external concept. - */ -export enum ConceptMappingType { - BroadMatch = "BROAD_MATCH", - CloseMatch = "CLOSE_MATCH", - ExactMatch = "EXACT_MATCH", - NarrowMatch = "NARROW_MATCH", - RelatedMatch = "RELATED_MATCH", - SameAs = "SAME_AS", -} - export enum OperationType { AddRelationship = "ADD_RELATIONSHIP", CreateGlossary = "CREATE_GLOSSARY", CreateTerm = "CREATE_TERM", DeleteAttribute = "DELETE_ATTRIBUTE", DeleteAxiom = "DELETE_AXIOM", + DeleteConcept = "DELETE_CONCEPT", DeleteMapping = "DELETE_MAPPING", + DeleteProperty = "DELETE_PROPERTY", DeleteRelationship = "DELETE_RELATIONSHIP", DeleteTerm = "DELETE_TERM", UpdateRelationship = "UPDATE_RELATIONSHIP", UpdateTerm = "UPDATE_TERM", UpsertAttribute = "UPSERT_ATTRIBUTE", UpsertAxiom = "UPSERT_AXIOM", + UpsertConcept = "UPSERT_CONCEPT", UpsertMapping = "UPSERT_MAPPING", + UpsertProperty = "UPSERT_PROPERTY", +} + +/** + * A formal object, data, or annotation property owned by an ontology, independent of + * glossary relation types. + */ +export interface OntologyProperty { + changeDescription?: ChangeDescription; + characteristics?: Characteristic[]; + deleted?: boolean; + description?: string; + /** + * Semantic IRIs of properties disjoint with this property. + */ + disjointWith?: string[]; + displayName?: string; + /** + * Semantic IRIs of the classes in the property domain. + */ + domain?: string[]; + /** + * Allowed typed values for a DATA property, projected as an OWL data enumeration. An absent + * datatype denotes xsd:string. Empty means no enumeration constraint. + */ + enumValues?: Literal[]; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Semantic IRI of the inverse object property. + */ + inverse?: string; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + /** + * Identifier intent for a DATA property. This annotation does not assert functionality, an + * OWL key, or uniqueness in source data. + */ + isIdentifier?: boolean; + kind: Kind; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + /** + * Ordered object-property IRIs whose composition implies this property. + */ + propertyChain?: string[]; + provider?: ProviderType; + /** + * Semantic IRIs of range classes or datatypes. + */ + range?: string[]; + /** + * Unit IRI or display symbol for a DATA property, retained as an annotation without + * implying conversion. + */ + unit?: string; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +export enum Characteristic { + Asymmetric = "ASYMMETRIC", + Functional = "FUNCTIONAL", + InverseFunctional = "INVERSE_FUNCTIONAL", + Irreflexive = "IRREFLEXIVE", + Reflexive = "REFLEXIVE", + Symmetric = "SYMMETRIC", + Transitive = "TRANSITIVE", +} + +export enum Kind { + Annotation = "ANNOTATION", + Data = "DATA", + Object = "OBJECT", } /** @@ -1124,19 +1275,6 @@ export interface GlossaryTerm { votes?: Votes; } -/** - * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which - * emits the term as an `owl:Class` and a `skos:Concept`. - * - * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies - * round-trip with the typing they were authored with. - */ -export enum OntologyConceptType { - Both = "BOTH", - OwlClass = "OWL_CLASS", - SkosConcept = "SKOS_CONCEPT", -} - /** * Pinned source provenance when this term belongs to an application ontology subset. * diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyReleaseDiff.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyReleaseDiff.ts new file mode 100644 index 000000000000..fff9957645b8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyReleaseDiff.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * A paginated comparison of asserted definitions in two immutable releases. An omitted base + * compares with an empty ontology. Operational changes are excluded; this is not a + * logical-equivalence result. + */ +export interface OntologyReleaseDiff { + baseContentDigest?: string; + baseReleaseId?: string; + contentDigest: string; + /** + * Changed members ordered by member kind and entity UUID. References identify the exact + * snapshots to inspect. + */ + data: MemberChange[]; + paging: Paging; + releaseId: string; +} + +export interface MemberChange { + /** + * Release containing the proposed member. Absent for removals. + */ + afterReleaseId?: string; + /** + * Release containing the previous member. Absent for additions. + */ + beforeReleaseId?: string; + changeType: ChangeType; + entity: EntityReference; + memberKind: MemberKind; + ontologyId: string; +} + +export enum ChangeType { + Added = "ADDED", + Changed = "CHANGED", + Removed = "REMOVED", +} + +/** + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * Unique id used to identify an entity. + * + * Release containing the proposed member. Absent for removals. + * + * Release containing the previous member. Absent for additions. + * + * Unique identifier that identifies an entity instance. + * + * Optional description of entity. + * + * Text in Markdown format. + * + * Link to the entity resource. + * + * URI that points to a resource. + */ +export enum MemberKind { + Association = "ASSOCIATION", + Axiom = "AXIOM", + Concept = "CONCEPT", + Ontology = "ONTOLOGY", + Property = "PROPERTY", + Shape = "SHAPE", +} + +/** + * Type used for cursor based pagination information in GET list responses. + */ +export interface Paging { + /** + * After cursor used for getting the next page (see API pagination for details). + */ + after?: string; + /** + * Before cursor used for getting the previous page (see API pagination for details). + */ + before?: string; + /** + * Limit used in case of offset based pagination. + */ + limit?: number; + /** + * Offset used in case of offset based pagination. + */ + offset?: number; + /** + * Total number of entries available to page through. + */ + total: number; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyReleaseStructureReport.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyReleaseStructureReport.ts new file mode 100644 index 000000000000..80d7a4fb499a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyReleaseStructureReport.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Declaration, reference-kind and axiom-shape checks over a sealed release and its pinned + * imports. This is not a complete OWL 2 DL profile check, a logical consistency result, or + * publication approval. + */ +export interface OntologyReleaseStructureReport { + contentDigest: string; + releaseId: string; + /** + * True only when these structural checks find no violations. Other required publication + * checks are independent. + */ + valid: boolean; + /** + * Total findings, including those beyond the response limit. + */ + violationCount: number; + /** + * At most 1000 findings in release/member order. Compare the length with violationCount to + * detect truncation. + */ + violations: Issue[]; +} + +export interface Issue { + memberId: string; + memberKind: MemberKind; + /** + * Owning release when validating captured content; absent for a working definition. + */ + releaseId?: string; + violation: OntologyProfileViolation; +} + +/** + * Unique id used to identify an entity. + * + * Owning release when validating captured content; absent for a working definition. + */ +export enum MemberKind { + Association = "ASSOCIATION", + Axiom = "AXIOM", + Concept = "CONCEPT", + Ontology = "ONTOLOGY", + Property = "PROPERTY", + Shape = "SHAPE", +} + +/** + * A typed OWL 2 DL profile guardrail finding. + */ +export interface OntologyProfileViolation { + code: Code; + message: string; + path: string; + severity: Severity; +} + +export enum Code { + IllegalPunning = "ILLEGAL_PUNNING", + InvalidAxiomShape = "INVALID_AXIOM_SHAPE", + InvalidClassKind = "INVALID_CLASS_KIND", + InvalidExpressionShape = "INVALID_EXPRESSION_SHAPE", + NonSimpleCardinality = "NON_SIMPLE_CARDINALITY", + NonSimpleProperty = "NON_SIMPLE_PROPERTY", + PropertyChainRegularity = "PROPERTY_CHAIN_REGULARITY", + ReservedSubjectIRI = "RESERVED_SUBJECT_IRI", + SubclassCycle = "SUBCLASS_CYCLE", + TransitiveCardinality = "TRANSITIVE_CARDINALITY", + UndeclaredClass = "UNDECLARED_CLASS", + UndeclaredProperty = "UNDECLARED_PROPERTY", +} + +export enum Severity { + Error = "ERROR", + Warning = "WARNING", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyStructuralMergeResult.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyStructuralMergeResult.ts index c97cdd5df483..b7e8ded7c997 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyStructuralMergeResult.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyStructuralMergeResult.ts @@ -81,14 +81,18 @@ export enum OperationType { CreateTerm = "CREATE_TERM", DeleteAttribute = "DELETE_ATTRIBUTE", DeleteAxiom = "DELETE_AXIOM", + DeleteConcept = "DELETE_CONCEPT", DeleteMapping = "DELETE_MAPPING", + DeleteProperty = "DELETE_PROPERTY", DeleteRelationship = "DELETE_RELATIONSHIP", DeleteTerm = "DELETE_TERM", UpdateRelationship = "UPDATE_RELATIONSHIP", UpdateTerm = "UPDATE_TERM", UpsertAttribute = "UPSERT_ATTRIBUTE", UpsertAxiom = "UPSERT_AXIOM", + UpsertConcept = "UPSERT_CONCEPT", UpsertMapping = "UPSERT_MAPPING", + UpsertProperty = "UPSERT_PROPERTY", } /** diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyValidationError.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyValidationError.ts new file mode 100644 index 000000000000..532f587e34ae --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyValidationError.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * An invalid working model or release capture, with bounded findings identifying the + * affected definitions. + */ +export interface OntologyValidationError { + code: number; + message: string; + violationCount: number; + violations: Issue[]; +} + +export interface Issue { + memberId: string; + memberKind: MemberKind; + /** + * Owning release when validating captured content; absent for a working definition. + */ + releaseId?: string; + violation: OntologyProfileViolation; +} + +/** + * Unique id used to identify an entity. + * + * Owning release when validating captured content; absent for a working definition. + */ +export enum MemberKind { + Association = "ASSOCIATION", + Axiom = "AXIOM", + Concept = "CONCEPT", + Ontology = "ONTOLOGY", + Property = "PROPERTY", + Shape = "SHAPE", +} + +/** + * A typed OWL 2 DL profile guardrail finding. + */ +export interface OntologyProfileViolation { + code: Code; + message: string; + path: string; + severity: Severity; +} + +export enum Code { + IllegalPunning = "ILLEGAL_PUNNING", + InvalidAxiomShape = "INVALID_AXIOM_SHAPE", + InvalidClassKind = "INVALID_CLASS_KIND", + InvalidExpressionShape = "INVALID_EXPRESSION_SHAPE", + NonSimpleCardinality = "NON_SIMPLE_CARDINALITY", + NonSimpleProperty = "NON_SIMPLE_PROPERTY", + PropertyChainRegularity = "PROPERTY_CHAIN_REGULARITY", + ReservedSubjectIRI = "RESERVED_SUBJECT_IRI", + SubclassCycle = "SUBCLASS_CYCLE", + TransitiveCardinality = "TRANSITIVE_CARDINALITY", + UndeclaredClass = "UNDECLARED_CLASS", + UndeclaredProperty = "UNDECLARED_PROPERTY", +} + +export enum Severity { + Error = "ERROR", + Warning = "WARNING", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/updateOntologyChangeSet.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/updateOntologyChangeSet.ts index f0b48b0fc99c..0afcfcbfd17c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/updateOntologyChangeSet.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/updateOntologyChangeSet.ts @@ -37,6 +37,7 @@ export interface OntologyChangeOperation { * Version used for optimistic concurrency. Omitted for create operations. */ baseVersion?: number; + concept?: OntologyConcept; /** * Model confidence in this AI suggestion, from 0 to 1. */ @@ -49,6 +50,7 @@ export interface OntologyChangeOperation { */ modelId?: string; operationType: OperationType; + property?: OntologyProperty; /** * Model explanation of the evidence behind this AI suggestion. */ @@ -137,6 +139,8 @@ export enum DataType { * example, a table has an attribute called database of type EntityReference that captures * the relationship of a table `belongs to a` database. * + * Owning ontology entity. Glossaries do not own ontology definitions. + * * Reference to the data contract for this entity. * * Glossary that this term belongs to. @@ -203,13 +207,11 @@ export interface OntologyAxiom { deleted?: boolean; description: string; displayName: string; - entityStatus: EntityStatus; /** * Class expressions participating in the axiom, ordered from left to right. */ expressions: OntologyExpression[]; fullyQualifiedName: string; - glossary: EntityReference; href?: string; id: string; /** @@ -219,11 +221,11 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -305,28 +307,6 @@ export interface FieldChange { oldValue?: any; } -/** - * General lifecycle stage for entities that reference this vocabulary. Specialized entities - * can declare their own status schema and lifecycle policy. Entity types without an - * entityStatus property have no lifecycle. When a create request omits the stage, the - * server assigns the entity type's initial stage. - * - * Status of the Glossary. - * - * Approval status of the glossary term. - * - * Approval status of this relation edge. - */ -export enum EntityStatus { - Approved = "Approved", - Archived = "Archived", - Deprecated = "Deprecated", - Draft = "Draft", - InReview = "In Review", - Rejected = "Rejected", - Unprocessed = "Unprocessed", -} - /** * A recursive OWL class expression represented without untyped maps. * @@ -430,6 +410,101 @@ export enum ProviderType { User = "user", } +/** + * A formal class, SKOS concept, or both, owned by an ontology independently of glossary + * terms. + */ +export interface OntologyConcept { + changeDescription?: ChangeDescription; + conceptMappings?: ConceptMapping[]; + conceptType: OntologyConceptType; + deleted?: boolean; + description?: string; + displayName?: string; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + provider?: ProviderType; + /** + * Direct named subclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not + * stored or independently editable. + */ + subClasses?: string[]; + /** + * Direct named superclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. + * Not stored or independently editable. + */ + superClasses?: string[]; + synonyms?: string[]; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +/** + * Mapping from an ontology term to an external concept. + */ +export interface ConceptMapping { + /** + * External concept IRI to map this ontology term to. + */ + conceptIri: string; + /** + * Type of mapping used for the external concept alignment. + */ + mappingType: ConceptMappingType; + /** + * Optional external concept scheme IRI for the mapped concept. + */ + schemeIri?: string; + /** + * Optional source label or catalog for the external concept. + */ + source?: string; +} + +/** + * Type of mapping used for the external concept alignment. + * + * Type of mapping used to align this term with an external concept. + */ +export enum ConceptMappingType { + BroadMatch = "BROAD_MATCH", + CloseMatch = "CLOSE_MATCH", + ExactMatch = "EXACT_MATCH", + NarrowMatch = "NARROW_MATCH", + RelatedMatch = "RELATED_MATCH", + SameAs = "SAME_AS", +} + +/** + * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies + * round-trip with the typing they were authored with. + * + * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which + * emits the term as an `owl:Class` and a `skos:Concept`. + */ +export enum OntologyConceptType { + Both = "BOTH", + OwlClass = "OWL_CLASS", + SkosConcept = "SKOS_CONCEPT", +} + /** * This schema defines the Glossary entity. A Glossary is collection of hierarchical * GlossaryTerms. @@ -554,6 +629,28 @@ export interface Glossary { votes?: Votes; } +/** + * Status of the Glossary. + * + * General lifecycle stage for entities that reference this vocabulary. Specialized entities + * can declare their own status schema and lifecycle policy. Entity types without an + * entityStatus property have no lifecycle. When a create request omits the stage, the + * server assigns the entity type's initial stage. + * + * Approval status of the glossary term. + * + * Approval status of this relation edge. + */ +export enum EntityStatus { + Approved = "Approved", + Archived = "Archived", + Deprecated = "Deprecated", + Draft = "Draft", + InReview = "In Review", + Rejected = "Rejected", + Unprocessed = "Unprocessed", +} + export interface Namespace { /** * Full namespace IRI the prefix expands to (e.g., `http://example.com/ontology/hcp#`). @@ -862,56 +959,110 @@ export interface Votes { upVotes?: number; } -/** - * Mapping from an ontology term to an external concept. - */ -export interface ConceptMapping { - /** - * External concept IRI to map this ontology term to. - */ - conceptIri: string; - /** - * Type of mapping used for the external concept alignment. - */ - mappingType: ConceptMappingType; - /** - * Optional external concept scheme IRI for the mapped concept. - */ - schemeIri?: string; - /** - * Optional source label or catalog for the external concept. - */ - source?: string; -} - -/** - * Type of mapping used for the external concept alignment. - * - * Type of mapping used to align this term with an external concept. - */ -export enum ConceptMappingType { - BroadMatch = "BROAD_MATCH", - CloseMatch = "CLOSE_MATCH", - ExactMatch = "EXACT_MATCH", - NarrowMatch = "NARROW_MATCH", - RelatedMatch = "RELATED_MATCH", - SameAs = "SAME_AS", -} - export enum OperationType { AddRelationship = "ADD_RELATIONSHIP", CreateGlossary = "CREATE_GLOSSARY", CreateTerm = "CREATE_TERM", DeleteAttribute = "DELETE_ATTRIBUTE", DeleteAxiom = "DELETE_AXIOM", + DeleteConcept = "DELETE_CONCEPT", DeleteMapping = "DELETE_MAPPING", + DeleteProperty = "DELETE_PROPERTY", DeleteRelationship = "DELETE_RELATIONSHIP", DeleteTerm = "DELETE_TERM", UpdateRelationship = "UPDATE_RELATIONSHIP", UpdateTerm = "UPDATE_TERM", UpsertAttribute = "UPSERT_ATTRIBUTE", UpsertAxiom = "UPSERT_AXIOM", + UpsertConcept = "UPSERT_CONCEPT", UpsertMapping = "UPSERT_MAPPING", + UpsertProperty = "UPSERT_PROPERTY", +} + +/** + * A formal object, data, or annotation property owned by an ontology, independent of + * glossary relation types. + */ +export interface OntologyProperty { + changeDescription?: ChangeDescription; + characteristics?: Characteristic[]; + deleted?: boolean; + description?: string; + /** + * Semantic IRIs of properties disjoint with this property. + */ + disjointWith?: string[]; + displayName?: string; + /** + * Semantic IRIs of the classes in the property domain. + */ + domain?: string[]; + /** + * Allowed typed values for a DATA property, projected as an OWL data enumeration. An absent + * datatype denotes xsd:string. Empty means no enumeration constraint. + */ + enumValues?: Literal[]; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Semantic IRI of the inverse object property. + */ + inverse?: string; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + /** + * Identifier intent for a DATA property. This annotation does not assert functionality, an + * OWL key, or uniqueness in source data. + */ + isIdentifier?: boolean; + kind: Kind; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + /** + * Ordered object-property IRIs whose composition implies this property. + */ + propertyChain?: string[]; + provider?: ProviderType; + /** + * Semantic IRIs of range classes or datatypes. + */ + range?: string[]; + /** + * Unit IRI or display symbol for a DATA property, retained as an annotation without + * implying conversion. + */ + unit?: string; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +export enum Characteristic { + Asymmetric = "ASYMMETRIC", + Functional = "FUNCTIONAL", + InverseFunctional = "INVERSE_FUNCTIONAL", + Irreflexive = "IRREFLEXIVE", + Reflexive = "REFLEXIVE", + Symmetric = "SYMMETRIC", + Transitive = "TRANSITIVE", +} + +export enum Kind { + Annotation = "ANNOTATION", + Data = "DATA", + Object = "OBJECT", } /** @@ -1122,19 +1273,6 @@ export interface GlossaryTerm { votes?: Votes; } -/** - * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which - * emits the term as an `owl:Class` and a `skos:Concept`. - * - * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies - * round-trip with the typing they were authored with. - */ -export enum OntologyConceptType { - Both = "BOTH", - OwlClass = "OWL_CLASS", - SkosConcept = "SKOS_CONCEPT", -} - /** * Pinned source provenance when this term belongs to an application ontology subset. * diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontology.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontology.ts new file mode 100644 index 000000000000..44597b625ae3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontology.ts @@ -0,0 +1,193 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * An independently governed ontology namespace. Formal definitions are stored as separate + * entities. + */ +export interface Ontology { + changeDescription?: ChangeDescription; + deleted?: boolean; + description?: string; + displayName?: string; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Stable semantic identity of this ontology; independent of its name and catalog record IRI. + */ + iri: string; + layer?: Layer; + name: string; + /** + * Absolute namespace used to mint persistent IRIs for native definitions. + */ + namespace: string; + owners?: EntityReference[]; + prefixes?: Prefix[]; + provider?: ProviderType; + reviewers?: EntityReference[]; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +/** + * Description of the change. + */ +export interface ChangeDescription { + changeSummary?: { [key: string]: ChangeSummary }; + /** + * Names of fields added during the version changes. + */ + fieldsAdded?: FieldChange[]; + /** + * Fields deleted during the version changes with old value before deleted. + */ + fieldsDeleted?: FieldChange[]; + /** + * Fields modified during the version changes with old and new values. + */ + fieldsUpdated?: FieldChange[]; + /** + * When a change did not result in change, this could be same as the current version. + */ + previousVersion?: number; +} + +export interface ChangeSummary { + changedAt?: number; + /** + * Name of the user or bot who made this change + */ + changedBy?: string; + changeSource?: ChangeSource; + [property: string]: any; +} + +/** + * The source of the change. This will change based on the context of the change (example: + * manual vs programmatic) + */ +export enum ChangeSource { + Automated = "Automated", + Derived = "Derived", + Ingested = "Ingested", + Manual = "Manual", + Propagated = "Propagated", + Suggested = "Suggested", +} + +export interface FieldChange { + /** + * Name of the entity field that changed. + */ + name?: string; + /** + * New value of the field. Note that this is a JSON string and use the corresponding field + * type to deserialize it. + */ + newValue?: any; + /** + * Previous value of the field. Note that this is a JSON string and use the corresponding + * field type to deserialize it. + */ + oldValue?: any; +} + +/** + * Governance layer of an ontology model. + */ +export enum Layer { + L1 = "L1", + L2 = "L2", + L3 = "L3", +} + +/** + * This schema defines the EntityReferenceList type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * A compact IRI prefix and its absolute namespace. + */ +export interface Prefix { + namespace: string; + prefix: string; +} + +/** + * Type of provider of an entity. Some entities are provided by the `system`. Some are + * entities created and provided by the `user`. Typically `system` provide entities can't be + * deleted and can only be disabled. Some apps such as AutoPilot create entities with + * `automation` provider type. These entities can be deleted by the user. + */ +export enum ProviderType { + Automation = "automation", + System = "system", + User = "user", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyAxiom.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyAxiom.ts index 2a62c9a0c059..2e6d6c7a1a14 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyAxiom.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyAxiom.ts @@ -20,13 +20,11 @@ export interface OntologyAxiom { deleted?: boolean; description: string; displayName: string; - entityStatus: EntityStatus; /** * Class expressions participating in the axiom, ordered from left to right. */ expressions: OntologyExpression[]; fullyQualifiedName: string; - glossary: EntityReference; href?: string; id: string; /** @@ -36,11 +34,11 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -120,22 +118,6 @@ export interface FieldChange { oldValue?: any; } -/** - * General lifecycle stage for entities that reference this vocabulary. Specialized entities - * can declare their own status schema and lifecycle policy. Entity types without an - * entityStatus property have no lifecycle. When a create request omits the stage, the - * server assigns the entity type's initial stage. - */ -export enum EntityStatus { - Approved = "Approved", - Archived = "Archived", - Deprecated = "Deprecated", - Draft = "Draft", - InReview = "In Review", - Rejected = "Rejected", - Unprocessed = "Unprocessed", -} - /** * A recursive OWL class expression represented without untyped maps. * diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyChangeSet.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyChangeSet.ts index 9be7d507ba1c..cc44ae10dd82 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyChangeSet.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyChangeSet.ts @@ -15,22 +15,31 @@ */ export interface OntologyChangeSet { applicationResult?: OntologyChangeApplicationResult; - changeDescription?: ChangeDescription; - deleted?: boolean; - description: string; - displayName: string; - fullyQualifiedName: string; + /** + * Native working revision against which all operations were proposed. Required for native + * scope. + */ + baseWorkingRevision?: number; + changeDescription?: ChangeDescription; + deleted?: boolean; + description: string; + displayName: string; + fullyQualifiedName: string; /** * Ontology models affected by this change set. */ - glossaries: EntityReference[]; + glossaries?: EntityReference[]; href?: string; id: string; incrementalChangeDescription?: ChangeDescription; name: string; - operations: OntologyChangeOperation[]; - owners?: EntityReference[]; - provider?: ProviderType; + /** + * Native ontology scope. Mutually exclusive with glossaries. + */ + ontology?: EntityReference; + operations: OntologyChangeOperation[]; + owners?: EntityReference[]; + provider?: ProviderType; /** * Review task created when a suggester submits the change set. */ @@ -78,8 +87,12 @@ export interface OperationResult { * example, a table has an attribute called database of type EntityReference that captures * the relationship of a table `belongs to a` database. * + * Native ontology scope. Mutually exclusive with glossaries. + * * Ancestor concept that declares this attribute. Only set when `inherited` is true. * + * Owning ontology entity. Glossaries do not own ontology definitions. + * * Reference to the data contract for this entity. * * Glossary that this term belongs to. @@ -221,6 +234,7 @@ export interface OntologyChangeOperation { * Version used for optimistic concurrency. Omitted for create operations. */ baseVersion?: number; + concept?: OntologyConcept; /** * Model confidence in this AI suggestion, from 0 to 1. */ @@ -233,6 +247,7 @@ export interface OntologyChangeOperation { */ modelId?: string; operationType: OperationType; + property?: OntologyProperty; /** * Model explanation of the evidence behind this AI suggestion. */ @@ -318,13 +333,11 @@ export interface OntologyAxiom { deleted?: boolean; description: string; displayName: string; - entityStatus: EntityStatus; /** * Class expressions participating in the axiom, ordered from left to right. */ expressions: OntologyExpression[]; fullyQualifiedName: string; - glossary: EntityReference; href?: string; id: string; /** @@ -334,11 +347,11 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -355,28 +368,6 @@ export enum AxiomType { SubclassOf = "SUBCLASS_OF", } -/** - * General lifecycle stage for entities that reference this vocabulary. Specialized entities - * can declare their own status schema and lifecycle policy. Entity types without an - * entityStatus property have no lifecycle. When a create request omits the stage, the - * server assigns the entity type's initial stage. - * - * Status of the Glossary. - * - * Approval status of the glossary term. - * - * Approval status of this relation edge. - */ -export enum EntityStatus { - Approved = "Approved", - Archived = "Archived", - Deprecated = "Deprecated", - Draft = "Draft", - InReview = "In Review", - Rejected = "Rejected", - Unprocessed = "Unprocessed", -} - /** * A recursive OWL class expression represented without untyped maps. * @@ -480,6 +471,101 @@ export enum ProviderType { User = "user", } +/** + * A formal class, SKOS concept, or both, owned by an ontology independently of glossary + * terms. + */ +export interface OntologyConcept { + changeDescription?: ChangeDescription; + conceptMappings?: ConceptMapping[]; + conceptType: OntologyConceptType; + deleted?: boolean; + description?: string; + displayName?: string; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + provider?: ProviderType; + /** + * Direct named subclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not + * stored or independently editable. + */ + subClasses?: string[]; + /** + * Direct named superclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. + * Not stored or independently editable. + */ + superClasses?: string[]; + synonyms?: string[]; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +/** + * Mapping from an ontology term to an external concept. + */ +export interface ConceptMapping { + /** + * External concept IRI to map this ontology term to. + */ + conceptIri: string; + /** + * Type of mapping used for the external concept alignment. + */ + mappingType: ConceptMappingType; + /** + * Optional external concept scheme IRI for the mapped concept. + */ + schemeIri?: string; + /** + * Optional source label or catalog for the external concept. + */ + source?: string; +} + +/** + * Type of mapping used for the external concept alignment. + * + * Type of mapping used to align this term with an external concept. + */ +export enum ConceptMappingType { + BroadMatch = "BROAD_MATCH", + CloseMatch = "CLOSE_MATCH", + ExactMatch = "EXACT_MATCH", + NarrowMatch = "NARROW_MATCH", + RelatedMatch = "RELATED_MATCH", + SameAs = "SAME_AS", +} + +/** + * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies + * round-trip with the typing they were authored with. + * + * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which + * emits the term as an `owl:Class` and a `skos:Concept`. + */ +export enum OntologyConceptType { + Both = "BOTH", + OwlClass = "OWL_CLASS", + SkosConcept = "SKOS_CONCEPT", +} + /** * This schema defines the Glossary entity. A Glossary is collection of hierarchical * GlossaryTerms. @@ -604,6 +690,28 @@ export interface Glossary { votes?: Votes; } +/** + * Status of the Glossary. + * + * General lifecycle stage for entities that reference this vocabulary. Specialized entities + * can declare their own status schema and lifecycle policy. Entity types without an + * entityStatus property have no lifecycle. When a create request omits the stage, the + * server assigns the entity type's initial stage. + * + * Approval status of the glossary term. + * + * Approval status of this relation edge. + */ +export enum EntityStatus { + Approved = "Approved", + Archived = "Archived", + Deprecated = "Deprecated", + Draft = "Draft", + InReview = "In Review", + Rejected = "Rejected", + Unprocessed = "Unprocessed", +} + export interface Namespace { /** * Full namespace IRI the prefix expands to (e.g., `http://example.com/ontology/hcp#`). @@ -912,56 +1020,110 @@ export interface Votes { upVotes?: number; } -/** - * Mapping from an ontology term to an external concept. - */ -export interface ConceptMapping { - /** - * External concept IRI to map this ontology term to. - */ - conceptIri: string; - /** - * Type of mapping used for the external concept alignment. - */ - mappingType: ConceptMappingType; - /** - * Optional external concept scheme IRI for the mapped concept. - */ - schemeIri?: string; - /** - * Optional source label or catalog for the external concept. - */ - source?: string; -} - -/** - * Type of mapping used for the external concept alignment. - * - * Type of mapping used to align this term with an external concept. - */ -export enum ConceptMappingType { - BroadMatch = "BROAD_MATCH", - CloseMatch = "CLOSE_MATCH", - ExactMatch = "EXACT_MATCH", - NarrowMatch = "NARROW_MATCH", - RelatedMatch = "RELATED_MATCH", - SameAs = "SAME_AS", -} - export enum OperationType { AddRelationship = "ADD_RELATIONSHIP", CreateGlossary = "CREATE_GLOSSARY", CreateTerm = "CREATE_TERM", DeleteAttribute = "DELETE_ATTRIBUTE", DeleteAxiom = "DELETE_AXIOM", + DeleteConcept = "DELETE_CONCEPT", DeleteMapping = "DELETE_MAPPING", + DeleteProperty = "DELETE_PROPERTY", DeleteRelationship = "DELETE_RELATIONSHIP", DeleteTerm = "DELETE_TERM", UpdateRelationship = "UPDATE_RELATIONSHIP", UpdateTerm = "UPDATE_TERM", UpsertAttribute = "UPSERT_ATTRIBUTE", UpsertAxiom = "UPSERT_AXIOM", + UpsertConcept = "UPSERT_CONCEPT", UpsertMapping = "UPSERT_MAPPING", + UpsertProperty = "UPSERT_PROPERTY", +} + +/** + * A formal object, data, or annotation property owned by an ontology, independent of + * glossary relation types. + */ +export interface OntologyProperty { + changeDescription?: ChangeDescription; + characteristics?: Characteristic[]; + deleted?: boolean; + description?: string; + /** + * Semantic IRIs of properties disjoint with this property. + */ + disjointWith?: string[]; + displayName?: string; + /** + * Semantic IRIs of the classes in the property domain. + */ + domain?: string[]; + /** + * Allowed typed values for a DATA property, projected as an OWL data enumeration. An absent + * datatype denotes xsd:string. Empty means no enumeration constraint. + */ + enumValues?: Literal[]; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Semantic IRI of the inverse object property. + */ + inverse?: string; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + /** + * Identifier intent for a DATA property. This annotation does not assert functionality, an + * OWL key, or uniqueness in source data. + */ + isIdentifier?: boolean; + kind: Kind; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + /** + * Ordered object-property IRIs whose composition implies this property. + */ + propertyChain?: string[]; + provider?: ProviderType; + /** + * Semantic IRIs of range classes or datatypes. + */ + range?: string[]; + /** + * Unit IRI or display symbol for a DATA property, retained as an annotation without + * implying conversion. + */ + unit?: string; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +export enum Characteristic { + Asymmetric = "ASYMMETRIC", + Functional = "FUNCTIONAL", + InverseFunctional = "INVERSE_FUNCTIONAL", + Irreflexive = "IRREFLEXIVE", + Reflexive = "REFLEXIVE", + Symmetric = "SYMMETRIC", + Transitive = "TRANSITIVE", +} + +export enum Kind { + Annotation = "ANNOTATION", + Data = "DATA", + Object = "OBJECT", } /** @@ -1172,19 +1334,6 @@ export interface GlossaryTerm { votes?: Votes; } -/** - * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which - * emits the term as an `owl:Class` and a `skos:Concept`. - * - * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies - * round-trip with the typing they were authored with. - */ -export enum OntologyConceptType { - Both = "BOTH", - OwlClass = "OWL_CLASS", - SkosConcept = "SKOS_CONCEPT", -} - /** * Pinned source provenance when this term belongs to an application ontology subset. * diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyConcept.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyConcept.ts new file mode 100644 index 000000000000..bfe61b4ab2ea --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyConcept.ts @@ -0,0 +1,234 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * A formal class, SKOS concept, or both, owned by an ontology independently of glossary + * terms. + */ +export interface OntologyConcept { + changeDescription?: ChangeDescription; + conceptMappings?: ConceptMapping[]; + conceptType: OntologyConceptType; + deleted?: boolean; + description?: string; + displayName?: string; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + provider?: ProviderType; + /** + * Direct named subclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not + * stored or independently editable. + */ + subClasses?: string[]; + /** + * Direct named superclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. + * Not stored or independently editable. + */ + superClasses?: string[]; + synonyms?: string[]; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +/** + * Description of the change. + */ +export interface ChangeDescription { + changeSummary?: { [key: string]: ChangeSummary }; + /** + * Names of fields added during the version changes. + */ + fieldsAdded?: FieldChange[]; + /** + * Fields deleted during the version changes with old value before deleted. + */ + fieldsDeleted?: FieldChange[]; + /** + * Fields modified during the version changes with old and new values. + */ + fieldsUpdated?: FieldChange[]; + /** + * When a change did not result in change, this could be same as the current version. + */ + previousVersion?: number; +} + +export interface ChangeSummary { + changedAt?: number; + /** + * Name of the user or bot who made this change + */ + changedBy?: string; + changeSource?: ChangeSource; + [property: string]: any; +} + +/** + * The source of the change. This will change based on the context of the change (example: + * manual vs programmatic) + */ +export enum ChangeSource { + Automated = "Automated", + Derived = "Derived", + Ingested = "Ingested", + Manual = "Manual", + Propagated = "Propagated", + Suggested = "Suggested", +} + +export interface FieldChange { + /** + * Name of the entity field that changed. + */ + name?: string; + /** + * New value of the field. Note that this is a JSON string and use the corresponding field + * type to deserialize it. + */ + newValue?: any; + /** + * Previous value of the field. Note that this is a JSON string and use the corresponding + * field type to deserialize it. + */ + oldValue?: any; +} + +/** + * Mapping from an ontology term to an external concept. + */ +export interface ConceptMapping { + /** + * External concept IRI to map this ontology term to. + */ + conceptIri: string; + /** + * Type of mapping used for the external concept alignment. + */ + mappingType: ConceptMappingType; + /** + * Optional external concept scheme IRI for the mapped concept. + */ + schemeIri?: string; + /** + * Optional source label or catalog for the external concept. + */ + source?: string; +} + +/** + * Type of mapping used for the external concept alignment. + * + * Type of mapping used to align this term with an external concept. + */ +export enum ConceptMappingType { + BroadMatch = "BROAD_MATCH", + CloseMatch = "CLOSE_MATCH", + ExactMatch = "EXACT_MATCH", + NarrowMatch = "NARROW_MATCH", + RelatedMatch = "RELATED_MATCH", + SameAs = "SAME_AS", +} + +/** + * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies + * round-trip with the typing they were authored with. + */ +export enum OntologyConceptType { + Both = "BOTH", + OwlClass = "OWL_CLASS", + SkosConcept = "SKOS_CONCEPT", +} + +/** + * Owning ontology entity. Glossaries do not own ontology definitions. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * This schema defines the EntityReferenceList type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * Type of provider of an entity. Some entities are provided by the `system`. Some are + * entities created and provided by the `user`. Typically `system` provide entities can't be + * deleted and can only be disabled. Some apps such as AutoPilot create entities with + * `automation` provider type. These entities can be deleted by the user. + */ +export enum ProviderType { + Automation = "automation", + System = "system", + User = "user", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyProperty.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyProperty.ts new file mode 100644 index 000000000000..dabd1f40134b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyProperty.ts @@ -0,0 +1,236 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * A formal object, data, or annotation property owned by an ontology, independent of + * glossary relation types. + */ +export interface OntologyProperty { + changeDescription?: ChangeDescription; + characteristics?: Characteristic[]; + deleted?: boolean; + description?: string; + /** + * Semantic IRIs of properties disjoint with this property. + */ + disjointWith?: string[]; + displayName?: string; + /** + * Semantic IRIs of the classes in the property domain. + */ + domain?: string[]; + /** + * Allowed typed values for a DATA property, projected as an OWL data enumeration. An absent + * datatype denotes xsd:string. Empty means no enumeration constraint. + */ + enumValues?: Literal[]; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Semantic IRI of the inverse object property. + */ + inverse?: string; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + /** + * Identifier intent for a DATA property. This annotation does not assert functionality, an + * OWL key, or uniqueness in source data. + */ + isIdentifier?: boolean; + kind: Kind; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + /** + * Ordered object-property IRIs whose composition implies this property. + */ + propertyChain?: string[]; + provider?: ProviderType; + /** + * Semantic IRIs of range classes or datatypes. + */ + range?: string[]; + /** + * Unit IRI or display symbol for a DATA property, retained as an annotation without + * implying conversion. + */ + unit?: string; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +/** + * Description of the change. + */ +export interface ChangeDescription { + changeSummary?: { [key: string]: ChangeSummary }; + /** + * Names of fields added during the version changes. + */ + fieldsAdded?: FieldChange[]; + /** + * Fields deleted during the version changes with old value before deleted. + */ + fieldsDeleted?: FieldChange[]; + /** + * Fields modified during the version changes with old and new values. + */ + fieldsUpdated?: FieldChange[]; + /** + * When a change did not result in change, this could be same as the current version. + */ + previousVersion?: number; +} + +export interface ChangeSummary { + changedAt?: number; + /** + * Name of the user or bot who made this change + */ + changedBy?: string; + changeSource?: ChangeSource; + [property: string]: any; +} + +/** + * The source of the change. This will change based on the context of the change (example: + * manual vs programmatic) + */ +export enum ChangeSource { + Automated = "Automated", + Derived = "Derived", + Ingested = "Ingested", + Manual = "Manual", + Propagated = "Propagated", + Suggested = "Suggested", +} + +export interface FieldChange { + /** + * Name of the entity field that changed. + */ + name?: string; + /** + * New value of the field. Note that this is a JSON string and use the corresponding field + * type to deserialize it. + */ + newValue?: any; + /** + * Previous value of the field. Note that this is a JSON string and use the corresponding + * field type to deserialize it. + */ + oldValue?: any; +} + +export enum Characteristic { + Asymmetric = "ASYMMETRIC", + Functional = "FUNCTIONAL", + InverseFunctional = "INVERSE_FUNCTIONAL", + Irreflexive = "IRREFLEXIVE", + Reflexive = "REFLEXIVE", + Symmetric = "SYMMETRIC", + Transitive = "TRANSITIVE", +} + +/** + * A typed OWL literal. + */ +export interface Literal { + datatypeIri?: string; + value: string; +} + +export enum Kind { + Annotation = "ANNOTATION", + Data = "DATA", + Object = "OBJECT", +} + +/** + * Owning ontology entity. Glossaries do not own ontology definitions. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * This schema defines the EntityReferenceList type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * Type of provider of an entity. Some entities are provided by the `system`. Some are + * entities created and provided by the `user`. Typically `system` provide entities can't be + * deleted and can only be disabled. Some apps such as AutoPilot create entities with + * `automation` provider type. These entities can be deleted by the user. + */ +export enum ProviderType { + Automation = "automation", + System = "system", + User = "user", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyRelease.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyRelease.ts new file mode 100644 index 000000000000..8f761c336a97 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyRelease.ts @@ -0,0 +1,238 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Sealed ontology content, stored as immutable SQL members and reviewed through the + * existing approval workflow. + */ +export interface OntologyRelease { + /** + * Active release at capture, retained as the immutable review comparison baseline. + */ + baseReleaseId?: string; + changeDescription?: ChangeDescription; + /** + * SHA-256 of the sealed manifest, including ordered member identities and payload digests. + */ + contentDigest: string; + deleted?: boolean; + description?: string; + displayName?: string; + /** + * Canonical manifest and member encoding used to reproduce the digest. + */ + encodingVersion: EncodingVersion; + entityStatus: EntityStatus; + fullyQualifiedName: string; + href?: string; + id: string; + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Logical consistency has not been checked. Structural and OWL 2 DL profile checks do not + * establish consistency. + */ + logicalCheck: LogicalCheck; + memberCount: number; + name: string; + ontology: EntityReference; + ontologyIri: string; + owners?: EntityReference[]; + provider?: ProviderType; + /** + * Monotonic release number allocated under the owning ontology state lock. + */ + releaseRevision: number; + /** + * Author of the current governed content revision; assigned by the server. + */ + reviewAuthor?: string; + reviewers?: EntityReference[]; + /** + * Current approval task, assigned by the existing workflow infrastructure. Clients cannot + * set this field. + */ + reviewTask?: string; + sealedAt: number; + sealedBy: string; + sourceWorkingRevision: number; + /** + * Total UTF-8 size of canonical member payloads. + */ + totalBytes: number; + updatedAt?: number; + updatedBy?: string; + version?: number; + versionIri: string; +} + +/** + * Description of the change. + */ +export interface ChangeDescription { + changeSummary?: { [key: string]: ChangeSummary }; + /** + * Names of fields added during the version changes. + */ + fieldsAdded?: FieldChange[]; + /** + * Fields deleted during the version changes with old value before deleted. + */ + fieldsDeleted?: FieldChange[]; + /** + * Fields modified during the version changes with old and new values. + */ + fieldsUpdated?: FieldChange[]; + /** + * When a change did not result in change, this could be same as the current version. + */ + previousVersion?: number; +} + +export interface ChangeSummary { + changedAt?: number; + /** + * Name of the user or bot who made this change + */ + changedBy?: string; + changeSource?: ChangeSource; + [property: string]: any; +} + +/** + * The source of the change. This will change based on the context of the change (example: + * manual vs programmatic) + */ +export enum ChangeSource { + Automated = "Automated", + Derived = "Derived", + Ingested = "Ingested", + Manual = "Manual", + Propagated = "Propagated", + Suggested = "Suggested", +} + +export interface FieldChange { + /** + * Name of the entity field that changed. + */ + name?: string; + /** + * New value of the field. Note that this is a JSON string and use the corresponding field + * type to deserialize it. + */ + newValue?: any; + /** + * Previous value of the field. Note that this is a JSON string and use the corresponding + * field type to deserialize it. + */ + oldValue?: any; +} + +/** + * Canonical manifest and member encoding used to reproduce the digest. + */ +export enum EncodingVersion { + OntologyReleaseV1 = "ontology-release-v1", +} + +/** + * General lifecycle stage for entities that reference this vocabulary. Specialized entities + * can declare their own status schema and lifecycle policy. Entity types without an + * entityStatus property have no lifecycle. When a create request omits the stage, the + * server assigns the entity type's initial stage. + */ +export enum EntityStatus { + Approved = "Approved", + Archived = "Archived", + Deprecated = "Deprecated", + Draft = "Draft", + InReview = "In Review", + Rejected = "Rejected", + Unprocessed = "Unprocessed", +} + +/** + * Logical consistency has not been checked. Structural and OWL 2 DL profile checks do not + * establish consistency. + */ +export enum LogicalCheck { + NotRun = "NOT_RUN", +} + +/** + * Owning ontology entity. Glossaries do not own ontology definitions. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + * + * This schema defines the EntityReferenceList type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * Type of provider of an entity. Some entities are provided by the `system`. Some are + * entities created and provided by the `user`. Typically `system` provide entities can't be + * deleted and can only be disabled. Some apps such as AutoPilot create entities with + * `automation` provider type. These entities can be deleted by the user. + */ +export enum ProviderType { + Automation = "automation", + System = "system", + User = "user", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/tasks/task.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/tasks/task.ts index f320c56bb6c9..f7d5d7fcb2e3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/tasks/task.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/tasks/task.ts @@ -107,6 +107,10 @@ export interface Task { */ id: string; name: string; + /** + * Immutable, server-managed review target for ontology approval tasks. + */ + ontologyReview?: OntologyReviewTarget; /** * Task-specific payload validated at runtime by the resolved TaskFormSchema for the task * type and category. @@ -486,6 +490,28 @@ export enum SyncStatus { Synced = "synced", } +/** + * Immutable, server-managed review target for ontology approval tasks. + * + * Server-captured immutable ontology release reviewed by an existing approval workflow task. + */ +export interface OntologyReviewTarget { + author: string; + /** + * SHA-256 of the ontology-review-v3 sealed release identity. + */ + contentDigest: string; + entityId: string; + /** + * Entity-history version submitted for review; reviewers read this frozen release header. + */ + entityVersion: number; + /** + * Owning ontology ID used to serialize release review decisions. + */ + ontologyId: string; +} + /** * Priority level of the task. */ diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyChangeOperation.ts b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyChangeOperation.ts index 16fbcd8a6556..1710e7500620 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyChangeOperation.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyChangeOperation.ts @@ -20,6 +20,7 @@ export interface OntologyChangeOperation { * Version used for optimistic concurrency. Omitted for create operations. */ baseVersion?: number; + concept?: OntologyConcept; /** * Model confidence in this AI suggestion, from 0 to 1. */ @@ -32,6 +33,7 @@ export interface OntologyChangeOperation { */ modelId?: string; operationType: OperationType; + property?: OntologyProperty; /** * Model explanation of the evidence behind this AI suggestion. */ @@ -120,6 +122,8 @@ export enum DataType { * example, a table has an attribute called database of type EntityReference that captures * the relationship of a table `belongs to a` database. * + * Owning ontology entity. Glossaries do not own ontology definitions. + * * Reference to the data contract for this entity. * * Glossary that this term belongs to. @@ -186,13 +190,11 @@ export interface OntologyAxiom { deleted?: boolean; description: string; displayName: string; - entityStatus: EntityStatus; /** * Class expressions participating in the axiom, ordered from left to right. */ expressions: OntologyExpression[]; fullyQualifiedName: string; - glossary: EntityReference; href?: string; id: string; /** @@ -202,11 +204,11 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -288,28 +290,6 @@ export interface FieldChange { oldValue?: any; } -/** - * General lifecycle stage for entities that reference this vocabulary. Specialized entities - * can declare their own status schema and lifecycle policy. Entity types without an - * entityStatus property have no lifecycle. When a create request omits the stage, the - * server assigns the entity type's initial stage. - * - * Status of the Glossary. - * - * Approval status of the glossary term. - * - * Approval status of this relation edge. - */ -export enum EntityStatus { - Approved = "Approved", - Archived = "Archived", - Deprecated = "Deprecated", - Draft = "Draft", - InReview = "In Review", - Rejected = "Rejected", - Unprocessed = "Unprocessed", -} - /** * A recursive OWL class expression represented without untyped maps. * @@ -413,6 +393,101 @@ export enum ProviderType { User = "user", } +/** + * A formal class, SKOS concept, or both, owned by an ontology independently of glossary + * terms. + */ +export interface OntologyConcept { + changeDescription?: ChangeDescription; + conceptMappings?: ConceptMapping[]; + conceptType: OntologyConceptType; + deleted?: boolean; + description?: string; + displayName?: string; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + provider?: ProviderType; + /** + * Direct named subclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not + * stored or independently editable. + */ + subClasses?: string[]; + /** + * Direct named superclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. + * Not stored or independently editable. + */ + superClasses?: string[]; + synonyms?: string[]; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +/** + * Mapping from an ontology term to an external concept. + */ +export interface ConceptMapping { + /** + * External concept IRI to map this ontology term to. + */ + conceptIri: string; + /** + * Type of mapping used for the external concept alignment. + */ + mappingType: ConceptMappingType; + /** + * Optional external concept scheme IRI for the mapped concept. + */ + schemeIri?: string; + /** + * Optional source label or catalog for the external concept. + */ + source?: string; +} + +/** + * Type of mapping used for the external concept alignment. + * + * Type of mapping used to align this term with an external concept. + */ +export enum ConceptMappingType { + BroadMatch = "BROAD_MATCH", + CloseMatch = "CLOSE_MATCH", + ExactMatch = "EXACT_MATCH", + NarrowMatch = "NARROW_MATCH", + RelatedMatch = "RELATED_MATCH", + SameAs = "SAME_AS", +} + +/** + * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies + * round-trip with the typing they were authored with. + * + * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which + * emits the term as an `owl:Class` and a `skos:Concept`. + */ +export enum OntologyConceptType { + Both = "BOTH", + OwlClass = "OWL_CLASS", + SkosConcept = "SKOS_CONCEPT", +} + /** * This schema defines the Glossary entity. A Glossary is collection of hierarchical * GlossaryTerms. @@ -537,6 +612,28 @@ export interface Glossary { votes?: Votes; } +/** + * Status of the Glossary. + * + * General lifecycle stage for entities that reference this vocabulary. Specialized entities + * can declare their own status schema and lifecycle policy. Entity types without an + * entityStatus property have no lifecycle. When a create request omits the stage, the + * server assigns the entity type's initial stage. + * + * Approval status of the glossary term. + * + * Approval status of this relation edge. + */ +export enum EntityStatus { + Approved = "Approved", + Archived = "Archived", + Deprecated = "Deprecated", + Draft = "Draft", + InReview = "In Review", + Rejected = "Rejected", + Unprocessed = "Unprocessed", +} + export interface Namespace { /** * Full namespace IRI the prefix expands to (e.g., `http://example.com/ontology/hcp#`). @@ -845,56 +942,110 @@ export interface Votes { upVotes?: number; } -/** - * Mapping from an ontology term to an external concept. - */ -export interface ConceptMapping { - /** - * External concept IRI to map this ontology term to. - */ - conceptIri: string; - /** - * Type of mapping used for the external concept alignment. - */ - mappingType: ConceptMappingType; - /** - * Optional external concept scheme IRI for the mapped concept. - */ - schemeIri?: string; - /** - * Optional source label or catalog for the external concept. - */ - source?: string; -} - -/** - * Type of mapping used for the external concept alignment. - * - * Type of mapping used to align this term with an external concept. - */ -export enum ConceptMappingType { - BroadMatch = "BROAD_MATCH", - CloseMatch = "CLOSE_MATCH", - ExactMatch = "EXACT_MATCH", - NarrowMatch = "NARROW_MATCH", - RelatedMatch = "RELATED_MATCH", - SameAs = "SAME_AS", -} - export enum OperationType { AddRelationship = "ADD_RELATIONSHIP", CreateGlossary = "CREATE_GLOSSARY", CreateTerm = "CREATE_TERM", DeleteAttribute = "DELETE_ATTRIBUTE", DeleteAxiom = "DELETE_AXIOM", + DeleteConcept = "DELETE_CONCEPT", DeleteMapping = "DELETE_MAPPING", + DeleteProperty = "DELETE_PROPERTY", DeleteRelationship = "DELETE_RELATIONSHIP", DeleteTerm = "DELETE_TERM", UpdateRelationship = "UPDATE_RELATIONSHIP", UpdateTerm = "UPDATE_TERM", UpsertAttribute = "UPSERT_ATTRIBUTE", UpsertAxiom = "UPSERT_AXIOM", + UpsertConcept = "UPSERT_CONCEPT", UpsertMapping = "UPSERT_MAPPING", + UpsertProperty = "UPSERT_PROPERTY", +} + +/** + * A formal object, data, or annotation property owned by an ontology, independent of + * glossary relation types. + */ +export interface OntologyProperty { + changeDescription?: ChangeDescription; + characteristics?: Characteristic[]; + deleted?: boolean; + description?: string; + /** + * Semantic IRIs of properties disjoint with this property. + */ + disjointWith?: string[]; + displayName?: string; + /** + * Semantic IRIs of the classes in the property domain. + */ + domain?: string[]; + /** + * Allowed typed values for a DATA property, projected as an OWL data enumeration. An absent + * datatype denotes xsd:string. Empty means no enumeration constraint. + */ + enumValues?: Literal[]; + fullyQualifiedName: string; + href?: string; + id: string; + /** + * Bot user that performed the action on behalf of the actual user. + */ + impersonatedBy?: string; + incrementalChangeDescription?: ChangeDescription; + /** + * Semantic IRI of the inverse object property. + */ + inverse?: string; + /** + * Persisted semantic IRI. Renaming the catalog entity must preserve this identity. + */ + iri: string; + /** + * Identifier intent for a DATA property. This annotation does not assert functionality, an + * OWL key, or uniqueness in source data. + */ + isIdentifier?: boolean; + kind: Kind; + name: string; + /** + * Owning ontology entity. Glossaries do not own ontology definitions. + */ + ontology: EntityReference; + owners?: EntityReference[]; + /** + * Ordered object-property IRIs whose composition implies this property. + */ + propertyChain?: string[]; + provider?: ProviderType; + /** + * Semantic IRIs of range classes or datatypes. + */ + range?: string[]; + /** + * Unit IRI or display symbol for a DATA property, retained as an annotation without + * implying conversion. + */ + unit?: string; + updatedAt?: number; + updatedBy?: string; + version?: number; +} + +export enum Characteristic { + Asymmetric = "ASYMMETRIC", + Functional = "FUNCTIONAL", + InverseFunctional = "INVERSE_FUNCTIONAL", + Irreflexive = "IRREFLEXIVE", + Reflexive = "REFLEXIVE", + Symmetric = "SYMMETRIC", + Transitive = "TRANSITIVE", +} + +export enum Kind { + Annotation = "ANNOTATION", + Data = "DATA", + Object = "OBJECT", } /** @@ -1105,19 +1256,6 @@ export interface GlossaryTerm { votes?: Votes; } -/** - * Vocabulary the source ontology used to assert this concept. Defaults to `BOTH`, which - * emits the term as an `owl:Class` and a `skos:Concept`. - * - * Vocabulary a source ontology used to assert a concept. Preserved so imported ontologies - * round-trip with the typing they were authored with. - */ -export enum OntologyConceptType { - Both = "BOTH", - OwlClass = "OWL_CLASS", - SkosConcept = "SKOS_CONCEPT", -} - /** * Pinned source provenance when this term belongs to an application ontology subset. * diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyProfileViolation.ts b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyProfileViolation.ts index 0059e766af23..a6d9802bfa76 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyProfileViolation.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyProfileViolation.ts @@ -23,11 +23,16 @@ export interface OntologyProfileViolation { export enum Code { IllegalPunning = "ILLEGAL_PUNNING", InvalidAxiomShape = "INVALID_AXIOM_SHAPE", + InvalidClassKind = "INVALID_CLASS_KIND", InvalidExpressionShape = "INVALID_EXPRESSION_SHAPE", NonSimpleCardinality = "NON_SIMPLE_CARDINALITY", + NonSimpleProperty = "NON_SIMPLE_PROPERTY", PropertyChainRegularity = "PROPERTY_CHAIN_REGULARITY", ReservedSubjectIRI = "RESERVED_SUBJECT_IRI", + SubclassCycle = "SUBCLASS_CYCLE", TransitiveCardinality = "TRANSITIVE_CARDINALITY", + UndeclaredClass = "UNDECLARED_CLASS", + UndeclaredProperty = "UNDECLARED_PROPERTY", } export enum Severity { diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReference.ts b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReference.ts new file mode 100644 index 000000000000..4603cb6ce5af --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReference.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * A derived reference from a working ontology definition to an exact semantic IRI. + * References are scoped to their owning ontology, not resolved globally. + */ +export interface OntologyReference { + iri: string; + ontologyId: string; + role: Role; + source: EntityReference; +} + +export enum Role { + AxiomSubject = "AXIOM_SUBJECT", + ClassExpression = "CLASS_EXPRESSION", + Datatype = "DATATYPE", + DisjointProperty = "DISJOINT_PROPERTY", + Domain = "DOMAIN", + Individual = "INDIVIDUAL", + Inverse = "INVERSE", + MappingScheme = "MAPPING_SCHEME", + MappingTarget = "MAPPING_TARGET", + Property = "PROPERTY", + PropertyChain = "PROPERTY_CHAIN", + Range = "RANGE", + SuperClass = "SUPER_CLASS", +} + +/** + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReleaseEvent.ts b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReleaseEvent.ts new file mode 100644 index 000000000000..9c05dca7104a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReleaseEvent.ts @@ -0,0 +1,162 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Append-only evidence committed with an ontology release capture, completed approval + * workflow decision, or activation. + */ +export interface OntologyReleaseEvent { + /** + * The new activation revision committed with an ACTIVATED event, including rollback. + */ + activationRevision?: number; + contentDigest: string; + createdAt: number; + createdBy: string; + eventType: EventType; + id: string; + ontologyId: string; + /** + * Previously active release, absent for the first activation. + */ + previousReleaseId?: string; + releaseId: string; + resolution?: TaskResolution; + reviewTarget?: OntologyReviewTarget; + taskId?: string; + /** + * Distinct reviewers whose votes were accepted by the completed workflow. + */ + voters?: string[]; + workflowDefinitionId?: string; + workflowInstanceId?: string; +} + +export enum EventType { + Activated = "ACTIVATED", + Approved = "APPROVED", + Captured = "CAPTURED", + Rejected = "REJECTED", +} + +/** + * Details about how the task was resolved. + */ +export interface TaskResolution { + /** + * Optional comment explaining the resolution. + */ + comment?: string; + /** + * The new value that was applied when task was resolved (for update tasks). + */ + newValue?: string; + /** + * Structured resolution data submitted via the transition form at resolution time. + */ + payload?: { [key: string]: any }; + /** + * Timestamp when the task was resolved. + */ + resolvedAt?: number; + /** + * User who resolved the task. + */ + resolvedBy?: EntityReference; + type?: ResolutionType; +} + +/** + * User who resolved the task. + * + * This schema defines the EntityReference type used for referencing an entity. + * EntityReference is used for capturing relationships from one entity to another. For + * example, a table has an attribute called database of type EntityReference that captures + * the relationship of a table `belongs to a` database. + */ +export interface EntityReference { + /** + * If true the entity referred to has been soft-deleted. + */ + deleted?: boolean; + /** + * Optional description of entity. + */ + description?: string; + /** + * Display Name that identifies this entity. + */ + displayName?: string; + /** + * Fully qualified name of the entity instance. For entities such as tables, databases + * fullyQualifiedName is returned in this field. For entities that don't have name hierarchy + * such as `user` and `team` this will be same as the `name` field. + */ + fullyQualifiedName?: string; + /** + * Link to the entity resource. + */ + href?: string; + /** + * Unique identifier that identifies an entity instance. + */ + id: string; + /** + * If true the relationship indicated by this entity reference is inherited from the parent + * entity. + */ + inherited?: boolean; + /** + * Name of the entity instance. + */ + name?: string; + /** + * Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, + * `dashboardService`... + */ + type: string; +} + +/** + * How the task was resolved. + */ +export enum ResolutionType { + Approved = "Approved", + AutoApproved = "AutoApproved", + AutoRejected = "AutoRejected", + Cancelled = "Cancelled", + Completed = "Completed", + Expired = "Expired", + Rejected = "Rejected", + Revoked = "Revoked", + TimedOut = "TimedOut", +} + +/** + * Server-captured immutable ontology release reviewed by an existing approval workflow task. + */ +export interface OntologyReviewTarget { + author: string; + /** + * SHA-256 of the ontology-review-v3 sealed release identity. + */ + contentDigest: string; + entityId: string; + /** + * Entity-history version submitted for review; reviewers read this frozen release header. + */ + entityVersion: number; + /** + * Owning ontology ID used to serialize release review decisions. + */ + ontologyId: string; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReleaseMember.ts b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReleaseMember.ts new file mode 100644 index 000000000000..7bce86869589 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReleaseMember.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * An immutable, bounded snapshot in a sealed ontology release. + */ +export interface OntologyReleaseMember { + memberId: string; + memberKind: MemberKind; + /** + * Captured content; readers never resolve this through a mutable definition or entity + * history. + */ + payload: { [key: string]: any }; + payloadBytes: number; + payloadDigest: string; + releaseId: string; + sourceVersion?: number; +} + +export enum MemberKind { + Association = "ASSOCIATION", + Axiom = "AXIOM", + Concept = "CONCEPT", + Ontology = "ONTOLOGY", + Property = "PROPERTY", + Shape = "SHAPE", +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReviewTarget.ts b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReviewTarget.ts new file mode 100644 index 000000000000..e3cf0d119696 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReviewTarget.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Server-captured immutable ontology release reviewed by an existing approval workflow task. + */ +export interface OntologyReviewTarget { + author: string; + /** + * SHA-256 of the ontology-review-v3 sealed release identity. + */ + contentDigest: string; + entityId: string; + /** + * Entity-history version submitted for review; reviewers read this frozen release header. + */ + entityVersion: number; + /** + * Owning ontology ID used to serialize release review decisions. + */ + ontologyId: string; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyState.ts b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyState.ts new file mode 100644 index 000000000000..666a6c206307 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyState.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * SQL concurrency revisions and the active immutable release of an ontology. + */ +export interface OntologyState { + /** + * Compare-and-set revision, advanced by both activation and rollback. + */ + activationRevision: number; + activeReleaseId?: string; + lastReleaseRevision: number; + ontologyId: string; + /** + * Monotonic revision of committed working-model mutations. + */ + workingRevision: number; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/governance/ontology/useOntologyPublication.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/governance/ontology/useOntologyPublication.ts new file mode 100644 index 000000000000..2d2405f0d235 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/governance/ontology/useOntologyPublication.ts @@ -0,0 +1,142 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useQuery } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { + TaskCategory, + TaskType, +} from '../../../generated/api/tasks/createTask'; +import { EntityStatus } from '../../../generated/entity/data/ontologyRelease'; +import { + activateOntologyRelease, + captureOntologyRelease, + getOntologyRelease, + getOntologyState, +} from '../../../rest/ontologyAPI'; +import { createTask } from '../../../rest/tasksAPI'; +import { getEntityFeedLink } from '../../../utils/EntityPureUtils'; +import { getTaskDetailPath } from '../../../utils/TaskUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; + +export const useOntologyPublication = ( + ontologyId: string, + canEdit: boolean +) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [selectedId, setSelectedId] = useState(); + const [isBusy, setIsBusy] = useState(false); + const working = useQuery({ + queryKey: ['ontology-publication', ontologyId], + retry: false, + gcTime: 0, + queryFn: async ({ signal }) => ({ + state: await getOntologyState(ontologyId, signal), + }), + }); + const releaseId = selectedId ?? working.data?.state.activeReleaseId; + const selected = useQuery({ + queryKey: ['ontology-release', ontologyId, releaseId], + retry: false, + gcTime: 0, + enabled: Boolean(releaseId), + queryFn: async ({ signal }) => { + if (!releaseId) { + throw new Error('Missing ontology release'); + } + const release = await getOntologyRelease(releaseId, signal); + if (release.ontology.id !== ontologyId || release.id !== releaseId) { + throw new Error( + 'Ontology release identity does not match the selection' + ); + } + + return release; + }, + }); + const release = selected.data; + const refresh = async () => { + await Promise.all([ + working.refetch(), + ...(releaseId ? [selected.refetch()] : []), + ]); + }; + const run = async (action: () => Promise) => { + if (!canEdit || isBusy) { + return; + } + setIsBusy(true); + try { + await action(); + } catch (error) { + showErrorToast( + isAxiosError(error) ? error : t('server.unexpected-error') + ); + } finally { + await refresh(); + setIsBusy(false); + } + }; + const capture = () => + run(async () => { + if (!working.data) { + return; + } + const candidate = await captureOntologyRelease(ontologyId, { + expectedWorkingRevision: working.data.state.workingRevision, + }); + setSelectedId(candidate.id); + }); + const requestApproval = () => + run(async () => { + if (!release) { + return; + } + const task = await createTask({ + category: TaskCategory.Approval, + type: TaskType.RequestApproval, + about: getEntityFeedLink('ontologyRelease', release.fullyQualifiedName), + }); + navigate(getTaskDetailPath(task)); + }); + const activate = () => + run(async () => { + if ( + !release || + !working.data || + release.entityStatus !== EntityStatus.Approved + ) { + return; + } + await activateOntologyRelease(ontologyId, { + releaseId: release.id, + expectedActivationRevision: working.data.state.activationRevision, + }); + }); + + return { + working, + selected, + releaseId, + release, + setSelectedId, + isBusy, + refresh, + capture, + requestApproval, + activate, + }; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json index 4e4705a3c9cc..7172002269d1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json @@ -37,6 +37,7 @@ "action": "إجراء", "action-plural": "إجراءات", "action-required": "مطلوب إجراء", + "activate-release": "تفعيل الإصدار", "active": "نشط", "active-persona": "الشخصية النشطة", "active-recently": "نشط مؤخرًا", @@ -434,6 +435,7 @@ "caption-expected-value": "المتوقع {{value}}", "caption-learned-range": "النطاق المُتعلَّم (تلقائي)", "caption-threshold": "الحد {{value}}", + "capture-release": "إنشاء لقطة إصدار", "card": "بطاقة", "cardinality": "عدد العناصر", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "التطبيقات المعطّلة", "discard": "تجاهل", "discover": "اكتشاف", + "disjoint-properties": "خصائص متنافية", "dismiss": "تجاهل", "display-name": "اسم العرض", "display-name-lowercase": "اسم العرض", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} مرات", "n-times_few": "{{count}} مرات", "n-times_one": "{{count}} مرة", + "n-triples-nt": "N-Triples (.nt)", "name": "الاسم", "name-is-required": "الاسم مطلوب", "name-lowercase": "الاسم", "name-this-ingestion": "اسم هذا الاستيعاب", "name-this-service": "Name this service", + "namespace": "نطاق الأسماء", "narrow-match": "مطابقة ضيقة", "narrower": "أضيق", "native": "أصلي", @@ -2342,8 +2347,10 @@ "ontology-explorer": "مستكشف الأنطولوجيا", "ontology-health": "حالة الأنطولوجيا", "ontology-inferred": "الأنطولوجيا · مستنتَجة", + "ontology-iri": "IRI للأنطولوجيا", "ontology-library": "مكتبة الأنطولوجيا", "ontology-relation-plural": "علاقات الـ Ontology", + "ontology-release": "إصدار الأنطولوجيا", "ontology-studio": "استوديو الأنطولوجيا", "open": "فتح", "open-entity": "فتح {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "منتشر", "properties": "الخصائص", "property": "خاصية", + "property-chain": "سلسلة الخصائص", + "property-domain": "فئات المجال", + "property-iri": "IRI للخاصية", "property-plural": "Properties", "propose-term": "اقتراح مصطلح", "proposed-by": "مقترح من", @@ -2773,6 +2783,7 @@ "reposition": "إعادة تموضع", "repository-url": "رابط المستودع", "request": "طلب", + "request-approval": "طلب الموافقة", "request-created-by": "Request created by", "request-data-access": "طلب الوصول إلى البيانات", "request-method": "طريقة الطلب", @@ -2957,6 +2968,7 @@ "schema-text": "نص المخطط", "schema-type": "نوع المخطط", "schema-validation": "التحقق من المخطط", + "scheme-iri": "معرّف IRI لمخطط المفاهيم", "scim-token": "رمز SCIM", "scope": "النطاق", "scope-and-behaviour": "النطاق والسلوك", @@ -3277,6 +3289,7 @@ "submit-review": "إرسال المراجعة", "submit-review-count": "إرسال المراجعة ({{count}})", "submitted-by": "قُدم بواسطة", + "submitted-definition": "التعريف المقدّم", "submitted-on": "قُدم في", "submitting": "جارٍ الإرسال", "subscription": "اشتراك", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "أضف مفهومين على الأقل ونوع علاقة واحدًا قبل طلب الاقتراحات.", "ontology-ai-select-glossary": "حدد مسرد مصطلحات واحدًا لتقييد نطاق اقتراحات الذكاء الاصطناعي والتخويل.", "ontology-ai-standards-placeholder": "أدخل معايير مفصولة بفواصل، مثل FIBO وFHIR وschema.org", + "ontology-consistency-not-run": "لم يتم التحقق من الاتساق المنطقي لهذا الإصدار.", "ontology-edit-held-by": "يقوم {{holder}} بتحرير هذا المسرد في جلسة أخرى. أغلق تلك الجلسة أو انتظر دقيقة حتى تنتهي صلاحيتها، ثم حاول مرة أخرى.", "ontology-empty-description": "تلتقط الأنطولوجيا العلاقات بين مصطلحاتك — ما ينتمي إلى ماذا، وما يُشتق من ماذا — بحيث تصبح مفرداتك نموذجًا مترابطًا لعملك، وليست مجرد قائمة أبجدية.", "ontology-empty-title": "حوّل مسردك إلى خريطة توضح كيفية ترابط المفاهيم", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — الفئات وخصائص الكائنات", "ontology-format-skos-description": "مخطط مفاهيمي بتسميات وتعيينات", "ontology-graph-feature-description": "يُرسم كل رابط هنا — خريطة حية توضح كيف تتلاءم مفاهيمك معًا.", + "ontology-graph-limit": "يعرض الرسم البياني ما يصل إلى {{count}} مفهوم. استخدم متصفح النموذج للأنطولوجيات الأكبر.", "ontology-import-issues-count": "تم تخطي {{count}} عنصر أو أنها تحتاج إلى مراجعة", "ontology-import-summary": "تم العثور على {{terms}} مصطلح، و{{relations}} علاقة، و{{mappings}} تعيين مفاهيم، و{{properties}} خاصية مخصصة.", "ontology-imported-successfully": "تم استيراد الأنطولوجيا بنجاح", + "ontology-iri-per-line": "معرّف IRI واحد في كل سطر؛ يُحفظ ترتيب سلسلة الخصائص.", "ontology-library-description": "أنطولوجيات جاهزة ومتوافقة مع المعايير. ثبّتها كما هي، أو اختر الوحدات أولاً.", "ontology-library-load-error": "تعذّر تحميل مكتبة الأنطولوجيا.", "ontology-not-connected-description": "لا توجد علاقات لهذه المصطلحات بعد. اربطها لوضعها في الأنطولوجيا.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "أشقاء · كلاهما تحت {{term}}", "ontology-relation-subtype": "{{from}} هو {{to}}", "ontology-relationships-feature-description": "اربط المصطلحات بمعنى: «مرتبط بـ» أو «جزء من» أو أنواعك الخاصة مثل «محسوب من».", + "ontology-review-unavailable": "تعذّر تحميل النسخة المقدّمة. قد تكون غير متاحة أو قد لا تملك إذنًا لقراءتها.", "ontology-studio-description": "نمذجة مفاهيم الأعمال وكيفية ارتباطها ببعضها.", "ontology-studio-edit-coming-soon": "تأليف العلاقات المرئي قادم قريبًا. في الوقت الحالي، استكشف رسم الأنطولوجيا البياني أو نفّذ استعلامات SPARQL.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index 1bed99dd429f..1d6fa899a364 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -37,6 +37,7 @@ "action": "Aktion", "action-plural": "Aktionen", "action-required": "Aktion erforderlich", + "activate-release": "Version aktivieren", "active": "Aktiv", "active-persona": "Aktive Persona", "active-recently": "Kürzlich aktiv", @@ -434,6 +435,7 @@ "caption-expected-value": "erwartet {{value}}", "caption-learned-range": "gelernter Bereich (automatisch)", "caption-threshold": "Schwellenwert {{value}}", + "capture-release": "Version erfassen", "card": "Karte", "cardinality": "Kardinalität", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "Deaktivierte Anwendungen", "discard": "Verwerfen", "discover": "Entdecken", + "disjoint-properties": "Disjunkte Eigenschaften", "dismiss": "Verwerfen", "display-name": "Anzeigename", "display-name-lowercase": "anzeigename", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} mal", "n-times_few": "{{count}} mal", "n-times_one": "{{count}} mal", + "n-triples-nt": "N-Triples (.nt)", "name": "Name", "name-is-required": "Name ist erforderlich", "name-lowercase": "Name", "name-this-ingestion": "Diesen Ingestion-Prozess benennen", "name-this-service": "Name this service", + "namespace": "Namensraum", "narrow-match": "Spezifische Übereinstimmung", "narrower": "Enger", "native": "Nativ", @@ -2342,8 +2347,10 @@ "ontology-explorer": "Ontologie-Explorer", "ontology-health": "Ontologiezustand", "ontology-inferred": "Ontologie · abgeleitet", + "ontology-iri": "Ontologie-IRI", "ontology-library": "Ontologiebibliothek", "ontology-relation-plural": "Ontology-Beziehungen", + "ontology-release": "Ontologieversion", "ontology-studio": "Ontologie-Studio", "open": "Öffnen", "open-entity": "{{entity}} öffnen", @@ -2596,6 +2603,9 @@ "propagated": "Weitergeleitet", "properties": "Eigenschaften", "property": "Eigenschaft", + "property-chain": "Eigenschaftskette", + "property-domain": "Definitionsklassen", + "property-iri": "Eigenschafts-IRI", "property-plural": "Properties", "propose-term": "Begriff vorschlagen", "proposed-by": "Vorgeschlagen von", @@ -2773,6 +2783,7 @@ "reposition": "Neu positionieren", "repository-url": "Repository-URL", "request": "Anfrage", + "request-approval": "Genehmigung anfordern", "request-created-by": "Anfrage erstellt von", "request-data-access": "Datenzugriff anfordern", "request-method": "Anfrage Methode", @@ -2957,6 +2968,7 @@ "schema-text": "Schematext", "schema-type": "Schematyp", "schema-validation": "Schemavalidierung", + "scheme-iri": "IRI des Begriffsschemas", "scim-token": "SCIM-Token", "scope": "Bereich", "scope-and-behaviour": "Umfang und Verhalten", @@ -3277,6 +3289,7 @@ "submit-review": "Prüfung einreichen", "submit-review-count": "Prüfung einreichen ({{count}})", "submitted-by": "Eingereicht von", + "submitted-definition": "Eingereichte Definition", "submitted-on": "Eingereicht am", "submitting": "Wird gesendet", "subscription": "Abonnement", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "Fügen Sie mindestens zwei Konzepte und einen Beziehungstyp hinzu, bevor Sie Vorschläge anfordern.", "ontology-ai-select-glossary": "Wählen Sie ein Glossar aus, um KI-Vorschläge und Autorisierung einzugrenzen.", "ontology-ai-standards-placeholder": "Geben Sie durch Kommas getrennte Standards ein, zum Beispiel FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "Die logische Konsistenz dieser Version wurde noch nicht geprüft.", "ontology-edit-held-by": "{{holder}} bearbeitet dieses Glossar in einer anderen Sitzung. Schließen Sie diese Sitzung oder warten Sie eine Minute, bis sie abläuft, und versuchen Sie es dann erneut.", "ontology-empty-description": "Eine Ontologie erfasst die Beziehungen zwischen Ihren Begriffen – was zu was gehört, was sich woraus ableitet – sodass Ihr Vokabular zu einem vernetzten Modell Ihres Unternehmens wird und nicht nur zu einer alphabetischen Liste.", "ontology-empty-title": "Verwandeln Sie Ihr Glossar in eine Karte der Verbindungen zwischen Konzepten", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — Klassen & Objekteigenschaften", "ontology-format-skos-description": "Konzeptschema mit Labels & Zuordnungen", "ontology-graph-feature-description": "Jede Verbindung zeichnet sich hier ein – eine lebendige Karte, wie Ihre Konzepte zusammenpassen.", + "ontology-graph-limit": "Der Graph zeigt bis zu {{count}} Konzepte. Verwenden Sie für größere Ontologien den Modellbrowser.", "ontology-import-issues-count": "{{count}} Element(e) wurden übersprungen oder müssen überprüft werden", "ontology-import-summary": "{{terms}} Begriff(e), {{relations}} Beziehung(en), {{mappings}} Konzeptzuordnung(en) und {{properties}} benutzerdefinierte Eigenschaft(en) gefunden.", "ontology-imported-successfully": "Ontologie erfolgreich importiert", + "ontology-iri-per-line": "Eine IRI pro Zeile; die Reihenfolge der Eigenschaftskette bleibt erhalten.", "ontology-library-description": "Vorgefertigte, standardkonforme Ontologien. Unverändert installieren oder zuerst Module auswählen.", "ontology-library-load-error": "Die Ontologiebibliothek konnte nicht geladen werden.", "ontology-not-connected-description": "Diese Begriffe haben noch keine Beziehungen. Verbinden Sie sie, um sie in der Ontologie einzuordnen.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "Geschwister · beide unter {{term}}", "ontology-relation-subtype": "{{from}} ist ein {{to}}", "ontology-relationships-feature-description": "Verknüpfen Sie Begriffe mit Bedeutung: „Bezieht sich auf“, „Teil von“ oder Ihre eigenen Typen wie „berechnet aus“.", + "ontology-review-unavailable": "Die eingereichte Version konnte nicht geladen werden. Sie ist möglicherweise nicht verfügbar oder Sie haben keine Leseberechtigung.", "ontology-studio-description": "Modellieren Sie Geschäftskonzepte und ihre Beziehungen zueinander.", "ontology-studio-edit-coming-soon": "Die visuelle Bearbeitung von Beziehungen ist bald verfügbar. Erkunden Sie vorerst den Ontologiegraphen oder führen Sie SPARQL-Abfragen aus.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 587d4aea28cb..d9c8f0103ac6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -37,6 +37,7 @@ "action": "Action", "action-plural": "Actions", "action-required": "Action Required", + "activate-release": "Activate release", "active": "Active", "active-persona": "Active persona", "active-recently": "Active recently", @@ -434,6 +435,7 @@ "caption-expected-value": "expected {{value}}", "caption-learned-range": "learned range (auto)", "caption-threshold": "threshold {{value}}", + "capture-release": "Capture release", "card": "Card", "cardinality": "Cardinality", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "Disabled Applications", "discard": "Discard", "discover": "Discover", + "disjoint-properties": "Disjoint properties", "dismiss": "Dismiss", "display-name": "Display Name", "display-name-lowercase": "display name", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} times", "n-times_few": "{{count}} times", "n-times_one": "{{count}} time", + "n-triples-nt": "N-Triples (.nt)", "name": "Name", "name-is-required": "Name is required", "name-lowercase": "name", "name-this-ingestion": "Name this ingestion", "name-this-service": "Name this service", + "namespace": "Namespace", "narrow-match": "Narrow match", "narrower": "Narrower", "native": "Native", @@ -2342,8 +2347,10 @@ "ontology-explorer": "Ontology Explorer", "ontology-health": "Ontology health", "ontology-inferred": "Ontology · inferred", + "ontology-iri": "Ontology IRI", "ontology-library": "Ontology Library", "ontology-relation-plural": "Ontology relations", + "ontology-release": "Ontology release", "ontology-studio": "Ontology Studio", "open": "Open", "open-entity": "Open {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "Propagated", "properties": "Properties", "property": "Property", + "property-chain": "Property chain", + "property-domain": "Domain classes", + "property-iri": "Property IRI", "property-plural": "Properties", "propose-term": "Propose Term", "proposed-by": "Proposed by", @@ -2773,6 +2783,7 @@ "reposition": "Reposition", "repository-url": "Repository URL", "request": "Request", + "request-approval": "Request approval", "request-created-by": "Request created by", "request-data-access": "Request Data Access", "request-method": "Request Method", @@ -2957,6 +2968,7 @@ "schema-text": "Schema Text", "schema-type": "Schema Type", "schema-validation": "Schema Validation", + "scheme-iri": "Scheme IRI", "scim-token": "SCIM Token", "scope": "Scope", "scope-and-behaviour": "Scope & Behaviour", @@ -3277,6 +3289,7 @@ "submit-review": "Submit review", "submit-review-count": "Submit review ({{count}})", "submitted-by": "Submitted By", + "submitted-definition": "Submitted definition", "submitted-on": "Submitted On", "submitting": "Submitting", "subscription": "Subscription", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "Add at least two concepts and one relationship type before requesting proposals.", "ontology-ai-select-glossary": "Select one glossary to scope AI proposals and authorization.", "ontology-ai-standards-placeholder": "Enter comma-separated standards, for example FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "Logical consistency has not been checked for this release.", "ontology-edit-held-by": "{{holder}} is editing this glossary in another session. Close that session or wait a minute for it to expire, then try again.", "ontology-empty-description": "An ontology captures the relationships between your terms — what belongs to what, what derives from what — so your vocabulary becomes a connected model of your business, not just an alphabetical list.", "ontology-empty-title": "Turn your glossary into a map of how concepts connect", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — classes & object properties", "ontology-format-skos-description": "Concept scheme with labels & mappings", "ontology-graph-feature-description": "Every link draws itself here — a living map of how your concepts fit together.", + "ontology-graph-limit": "The graph displays up to {{count}} concepts. Use the model browser for larger ontologies.", "ontology-import-issues-count": "{{count}} item(s) were skipped or need review", "ontology-import-summary": "Found {{terms}} term(s), {{relations}} relation(s), {{mappings}} concept mapping(s), and {{properties}} custom property(ies).", "ontology-imported-successfully": "Ontology imported successfully", + "ontology-iri-per-line": "One IRI per line; property chain order is preserved.", "ontology-library-description": "Pre-built, standards-aligned ontologies — install, or customize and install.", "ontology-library-load-error": "The ontology library could not be loaded.", "ontology-not-connected-description": "These terms have no relationships yet. Connect them to place them in the ontology.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "Siblings · both under {{term}}", "ontology-relation-subtype": "{{from}} is a {{to}}", "ontology-relationships-feature-description": "Link terms with meaning \"Related To,\" \"Part Of,\" or your own types like \"calculated from.\"", + "ontology-review-unavailable": "The submitted version could not be loaded. It may be unavailable or you may not have permission to read it.", "ontology-studio-description": "Model business concepts and how they relate.", "ontology-studio-edit-coming-soon": "Visual relationship authoring is coming soon. For now, explore the ontology graph or run SPARQL queries.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index b05c08f565b8..0782c96b2bc6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -37,6 +37,7 @@ "action": "Acción", "action-plural": "Acciones", "action-required": "Acción requerida", + "activate-release": "Activar versión", "active": "Activo", "active-persona": "Persona activa", "active-recently": "Activo recientemente", @@ -434,6 +435,7 @@ "caption-expected-value": "esperado {{value}}", "caption-learned-range": "rango aprendido (auto)", "caption-threshold": "umbral {{value}}", + "capture-release": "Capturar versión", "card": "Tarjeta", "cardinality": "Cardinalidad", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "Aplicaciones deshabilitadas", "discard": "Descartar", "discover": "Descubrir", + "disjoint-properties": "Propiedades disjuntas", "dismiss": "Descartar", "display-name": "Nombre para mostrar", "display-name-lowercase": "nombre de visualización", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} veces", "n-times_few": "{{count}} veces", "n-times_one": "{{count}} vez", + "n-triples-nt": "N-Triples (.nt)", "name": "Nombre", "name-is-required": "El nombre es obligatorio", "name-lowercase": "nombre", "name-this-ingestion": "Nombrar esta ingestión", "name-this-service": "Name this service", + "namespace": "Espacio de nombres", "narrow-match": "Correspondencia estrecha", "narrower": "Más específico", "native": "Nativo", @@ -2342,8 +2347,10 @@ "ontology-explorer": "Explorador de Ontología", "ontology-health": "Estado de la ontología", "ontology-inferred": "Ontología · inferida", + "ontology-iri": "IRI de la ontología", "ontology-library": "Biblioteca de ontologías", "ontology-relation-plural": "Relaciones de Ontology", + "ontology-release": "Versión de ontología", "ontology-studio": "Estudio de Ontología", "open": "Abrir", "open-entity": "Abrir {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "Propagado", "properties": "Propiedades", "property": "Propiedad", + "property-chain": "Cadena de propiedades", + "property-domain": "Clases del dominio", + "property-iri": "IRI de la propiedad", "property-plural": "Properties", "propose-term": "Proponer término", "proposed-by": "Propuesto por", @@ -2773,6 +2783,7 @@ "reposition": "Reposicionar", "repository-url": "URL del Repositorio", "request": "Petición", + "request-approval": "Solicitar aprobación", "request-created-by": "Solicitud creada por", "request-data-access": "Solicitar acceso a datos", "request-method": "Petición Método", @@ -2957,6 +2968,7 @@ "schema-text": "Texto del Esquema", "schema-type": "Tipo del Esquema", "schema-validation": "Validación de esquema", + "scheme-iri": "IRI del esquema de conceptos", "scim-token": "Token SCIM", "scope": "Alcance", "scope-and-behaviour": "Alcance y comportamiento", @@ -3277,6 +3289,7 @@ "submit-review": "Enviar revisión", "submit-review-count": "Enviar revisión ({{count}})", "submitted-by": "Enviado por", + "submitted-definition": "Definición enviada", "submitted-on": "Enviado el", "submitting": "Enviando", "subscription": "Subscripción", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "Añada al menos dos conceptos y un tipo de relación antes de solicitar propuestas.", "ontology-ai-select-glossary": "Seleccione un glosario para delimitar las propuestas de IA y la autorización.", "ontology-ai-standards-placeholder": "Introduzca estándares separados por comas, por ejemplo FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "No se ha comprobado la coherencia lógica de esta versión.", "ontology-edit-held-by": "{{holder}} está editando este glosario en otra sesión. Cierra esa sesión o espera un minuto a que caduque y vuelve a intentarlo.", "ontology-empty-description": "Una ontología captura las relaciones entre tus términos —qué pertenece a qué, qué deriva de qué— para que tu vocabulario se convierta en un modelo conectado de tu negocio, no solo en una lista alfabética.", "ontology-empty-title": "Convierte tu glosario en un mapa de cómo se conectan los conceptos", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — clases y propiedades de objeto", "ontology-format-skos-description": "Esquema de conceptos con etiquetas y asignaciones", "ontology-graph-feature-description": "Cada enlace se dibuja aquí: un mapa vivo de cómo encajan tus conceptos.", + "ontology-graph-limit": "El grafo muestra hasta {{count}} conceptos. Use el explorador del modelo para ontologías más grandes.", "ontology-import-issues-count": "{{count}} elemento(s) se omitieron o necesitan revisión", "ontology-import-summary": "Se encontraron {{terms}} término(s), {{relations}} relación(es), {{mappings}} asignación(es) de conceptos y {{properties}} propiedad(es) personalizada(s).", "ontology-imported-successfully": "Ontología importada correctamente", + "ontology-iri-per-line": "Un IRI por línea; se conserva el orden de la cadena de propiedades.", "ontology-library-description": "Ontologías predefinidas y alineadas con estándares. Instálalas tal cual o elige primero los módulos.", "ontology-library-load-error": "No se pudo cargar la biblioteca de ontologías.", "ontology-not-connected-description": "Estos términos aún no tienen relaciones. Conéctalos para ubicarlos en la ontología.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "Hermanos · ambos bajo {{term}}", "ontology-relation-subtype": "{{from}} es un {{to}}", "ontology-relationships-feature-description": "Vincula términos con significado: «Relacionado con», «Parte de» o tus propios tipos como «calculado a partir de».", + "ontology-review-unavailable": "No se pudo cargar la versión enviada. Puede que no esté disponible o que no tenga permiso para leerla.", "ontology-studio-description": "Modela conceptos de negocio y cómo se relacionan.", "ontology-studio-edit-coming-soon": "La creación visual de relaciones estará disponible pronto. Por ahora, explora el grafo de la ontología o ejecuta consultas SPARQL.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 7940507c44ee..29f832851632 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -37,6 +37,7 @@ "action": "Action", "action-plural": "Actions", "action-required": "Action requise", + "activate-release": "Activer la version", "active": "Actif", "active-persona": "Persona active", "active-recently": "Actif récemment", @@ -434,6 +435,7 @@ "caption-expected-value": "attendu {{value}}", "caption-learned-range": "plage apprise (auto)", "caption-threshold": "seuil {{value}}", + "capture-release": "Capturer une version", "card": "Carte", "cardinality": "Cardinalité", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "Applications désactivées", "discard": "Abandonner", "discover": "Découvrir", + "disjoint-properties": "Propriétés disjointes", "dismiss": "Ignorer", "display-name": "Nom d'Affichage", "display-name-lowercase": "nom d'affichage", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} fois", "n-times_few": "{{count}} fois", "n-times_one": "{{count}} fois", + "n-triples-nt": "N-Triples (.nt)", "name": "Nom", "name-is-required": "Le nom est obligatoire", "name-lowercase": "nom", "name-this-ingestion": "Nommer cette ingestion", "name-this-service": "Name this service", + "namespace": "Espace de noms", "narrow-match": "Correspondance étroite", "narrower": "Plus étroit", "native": "Natif", @@ -2342,8 +2347,10 @@ "ontology-explorer": "Explorateur d'Ontologie", "ontology-health": "État de l’ontologie", "ontology-inferred": "Ontologie · inférée", + "ontology-iri": "IRI de l’ontologie", "ontology-library": "Bibliothèque d’ontologies", "ontology-relation-plural": "Relations de l'Ontology", + "ontology-release": "Version de l’ontologie", "ontology-studio": "Studio d'ontologie", "open": "Ouvrir", "open-entity": "Ouvrir {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "Propagé", "properties": "Propriétés", "property": "Propriété", + "property-chain": "Chaîne de propriétés", + "property-domain": "Classes du domaine", + "property-iri": "IRI de la propriété", "property-plural": "Properties", "propose-term": "Proposer un terme", "proposed-by": "Proposé par", @@ -2773,6 +2783,7 @@ "reposition": "Repositionner", "repository-url": "URL du Dépôt", "request": "Requête", + "request-approval": "Demander une approbation", "request-created-by": "Demande créée par", "request-data-access": "Demander l'accès aux données", "request-method": "Méthode de Requête", @@ -2957,6 +2968,7 @@ "schema-text": "Texte du Schéma", "schema-type": "Type de Schéma", "schema-validation": "Validation du schéma", + "scheme-iri": "IRI du schéma de concepts", "scim-token": "Jeton SCIM", "scope": "Portée", "scope-and-behaviour": "Portée et comportement", @@ -3277,6 +3289,7 @@ "submit-review": "Soumettre l'examen", "submit-review-count": "Soumettre l'examen ({{count}})", "submitted-by": "Soumis par", + "submitted-definition": "Définition soumise", "submitted-on": "Soumis le", "submitting": "Soumission en cours", "subscription": "Abonnement", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "Ajoutez au moins deux concepts et un type de relation avant de demander des propositions.", "ontology-ai-select-glossary": "Sélectionnez un glossaire pour délimiter les propositions de l’IA et l’autorisation.", "ontology-ai-standards-placeholder": "Saisissez des normes séparées par des virgules, par exemple FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "La cohérence logique de cette version n’a pas été vérifiée.", "ontology-edit-held-by": "{{holder}} modifie ce glossaire dans une autre session. Fermez cette session ou attendez une minute qu’elle expire, puis réessayez.", "ontology-empty-description": "Une ontologie capture les relations entre vos termes — ce qui appartient à quoi, ce qui dérive de quoi — afin que votre vocabulaire devienne un modèle connecté de votre activité, et non une simple liste alphabétique.", "ontology-empty-title": "Transformez votre glossaire en une carte des connexions entre concepts", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — classes et propriétés d'objet", "ontology-format-skos-description": "Schéma de concepts avec libellés et correspondances", "ontology-graph-feature-description": "Chaque lien se dessine ici : une carte vivante de la façon dont vos concepts s'assemblent.", + "ontology-graph-limit": "Le graphe affiche jusqu’à {{count}} concepts. Utilisez l’explorateur de modèle pour les ontologies plus grandes.", "ontology-import-issues-count": "{{count}} élément(s) ont été ignorés ou nécessitent une révision", "ontology-import-summary": "{{terms}} terme(s), {{relations}} relation(s), {{mappings}} correspondance(s) de concepts et {{properties}} propriété(s) personnalisée(s) trouvé(s).", "ontology-imported-successfully": "Ontologie importée avec succès", + "ontology-iri-per-line": "Un IRI par ligne ; l’ordre de la chaîne de propriétés est conservé.", "ontology-library-description": "Ontologies prêtes à l'emploi, conformes aux standards. Installez-les telles quelles ou choisissez d'abord les modules.", "ontology-library-load-error": "Impossible de charger la bibliothèque d’ontologies.", "ontology-not-connected-description": "Ces termes n'ont encore aucune relation. Connectez-les pour les placer dans l'ontologie.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "Frères · tous deux sous {{term}}", "ontology-relation-subtype": "{{from}} est un {{to}}", "ontology-relationships-feature-description": "Reliez les termes avec du sens : « Lié à », « Partie de » ou vos propres types comme « calculé à partir de ».", + "ontology-review-unavailable": "La version soumise n’a pas pu être chargée. Elle est peut-être indisponible ou vous n’avez pas l’autorisation de la lire.", "ontology-studio-description": "Modélisez les concepts métier et leurs relations.", "ontology-studio-edit-coming-soon": "L'édition visuelle des relations arrive bientôt. Pour l'instant, explorez le graphe d'ontologie ou exécutez des requêtes SPARQL.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index e7436ea0ffb4..c644be38dee4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -37,6 +37,7 @@ "action": "Acción", "action-plural": "Accións", "action-required": "Acción requirida", + "activate-release": "Activar versión", "active": "Activo", "active-persona": "Persona activa", "active-recently": "Activo recentemente", @@ -434,6 +435,7 @@ "caption-expected-value": "agardado {{value}}", "caption-learned-range": "intervalo aprendido (auto)", "caption-threshold": "limiar {{value}}", + "capture-release": "Capturar versión", "card": "Tarxeta", "cardinality": "Cardinalidade", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "Aplicacións desactivadas", "discard": "Descartar", "discover": "Descubrir", + "disjoint-properties": "Propiedades disxuntas", "dismiss": "Descartar", "display-name": "Nome para mostrar", "display-name-lowercase": "nome de visualización", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} veces", "n-times_few": "{{count}} veces", "n-times_one": "{{count}} vez", + "n-triples-nt": "N-Triples (.nt)", "name": "Nome", "name-is-required": "O nome é obrigatorio", "name-lowercase": "nome", "name-this-ingestion": "Nomear esta inxestión", "name-this-service": "Name this service", + "namespace": "Espazo de nomes", "narrow-match": "Correspondencia estreita", "narrower": "Máis específico", "native": "Nativo", @@ -2342,8 +2347,10 @@ "ontology-explorer": "Explorador de Ontoloxía", "ontology-health": "Estado da ontoloxía", "ontology-inferred": "Ontoloxía · inferida", + "ontology-iri": "IRI da ontoloxía", "ontology-library": "Biblioteca de ontoloxías", "ontology-relation-plural": "Relacións de ontoloxía", + "ontology-release": "Versión da ontoloxía", "ontology-studio": "Estudio de Ontoloxía", "open": "Abrir", "open-entity": "Abrir {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "Propagado", "properties": "Propiedades", "property": "Propiedade", + "property-chain": "Cadea de propiedades", + "property-domain": "Clases do dominio", + "property-iri": "IRI da propiedade", "property-plural": "Properties", "propose-term": "Propoñer termo", "proposed-by": "Proposto por", @@ -2773,6 +2783,7 @@ "reposition": "Reposicionar", "repository-url": "URL do Repositorio", "request": "Solicitude", + "request-approval": "Solicitar aprobación", "request-created-by": "Solicitude creada por", "request-data-access": "Solicitar acceso a datos", "request-method": "Método de solicitude", @@ -2957,6 +2968,7 @@ "schema-text": "Texto do esquema", "schema-type": "Tipo de esquema", "schema-validation": "Validación de esquema", + "scheme-iri": "IRI do esquema de conceptos", "scim-token": "Token SCIM", "scope": "Alcance", "scope-and-behaviour": "Ámbito e comportamento", @@ -3277,6 +3289,7 @@ "submit-review": "Enviar revisión", "submit-review-count": "Enviar revisión ({{count}})", "submitted-by": "Enviado por", + "submitted-definition": "Definición enviada", "submitted-on": "Enviado o", "submitting": "Enviando", "subscription": "Subscrición", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "Engade polo menos dous conceptos e un tipo de relación antes de solicitar propostas.", "ontology-ai-select-glossary": "Selecciona un glosario para delimitar as propostas de IA e a autorización.", "ontology-ai-standards-placeholder": "Introduce estándares separados por comas, por exemplo FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "Non se comprobou a coherencia lóxica desta versión.", "ontology-edit-held-by": "{{holder}} está a editar este glosario noutra sesión. Pecha esa sesión ou agarda un minuto a que caduque e téntao de novo.", "ontology-empty-description": "Unha ontoloxía capta as relacións entre os teus termos —que pertence a que, que deriva de que— para que o teu vocabulario se converta nun modelo conectado do teu negocio, non só nunha lista alfabética.", "ontology-empty-title": "Converte o teu glosario nun mapa de como se conectan os conceptos", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — clases e propiedades de obxecto", "ontology-format-skos-description": "Esquema de conceptos con etiquetas e asignacións", "ontology-graph-feature-description": "Cada ligazón debúxase aquí: un mapa vivo de como encaixan os teus conceptos.", + "ontology-graph-limit": "O grafo amosa ata {{count}} conceptos. Use o explorador do modelo para ontoloxías máis grandes.", "ontology-import-issues-count": "{{count}} elemento(s) omitíronse ou necesitan revisión", "ontology-import-summary": "Atopáronse {{terms}} termo(s), {{relations}} relación(s), {{mappings}} asignación(s) de conceptos e {{properties}} propiedade(s) personalizada(s).", "ontology-imported-successfully": "Ontoloxía importada correctamente", + "ontology-iri-per-line": "Un IRI por liña; consérvase a orde da cadea de propiedades.", "ontology-library-description": "Ontoloxías predefinidas e aliñadas con estándares. Instálaas tal cal ou escolle primeiro os módulos.", "ontology-library-load-error": "Non se puido cargar a biblioteca de ontoloxías.", "ontology-not-connected-description": "Estes termos aínda non teñen relacións. Conéctaos para situalos na ontoloxía.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "Irmáns · ambos baixo {{term}}", "ontology-relation-subtype": "{{from}} é un {{to}}", "ontology-relationships-feature-description": "Vincula termos con significado: «Relacionado con», «Parte de» ou os teus propios tipos como «calculado a partir de».", + "ontology-review-unavailable": "Non se puido cargar a versión enviada. Pode que non estea dispoñible ou que non teña permiso para lela.", "ontology-studio-description": "Modela conceptos de negocio e como se relacionan.", "ontology-studio-edit-coming-soon": "A creación visual de relacións estará dispoñible en breve. Polo de agora, explora o grafo da ontoloxía ou executa consultas SPARQL.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index 419c25c2edb7..10ca6f0fd9dd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -37,6 +37,7 @@ "action": "פעולה", "action-plural": "פעולות", "action-required": "פעולה נדרשת", + "activate-release": "הפעלת גרסה", "active": "פעיל", "active-persona": "פרסונה פעילה", "active-recently": "פעיל לאחרונה", @@ -434,6 +435,7 @@ "caption-expected-value": "צפוי {{value}}", "caption-learned-range": "טווח נלמד (אוטומטי)", "caption-threshold": "סף {{value}}", + "capture-release": "יצירת תמונת מצב של גרסה", "card": "כרטיס", "cardinality": "עוצמה", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "אפליקציות מושבתות", "discard": "זריקה", "discover": "גלה", + "disjoint-properties": "מאפיינים זרים", "dismiss": "דחייה", "display-name": "שם הצגה", "display-name-lowercase": "שם תצוגה", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} פעמים", "n-times_few": "{{count}} פעמים", "n-times_one": "פעם אחת", + "n-triples-nt": "N-Triples (.nt)", "name": "שם", "name-is-required": "שם הוא שדה חובה", "name-lowercase": "שם", "name-this-ingestion": "שם לעיכול זה", "name-this-service": "Name this service", + "namespace": "מרחב שמות", "narrow-match": "התאמה צרה", "narrower": "צר יותר", "native": "מקורי", @@ -2342,8 +2347,10 @@ "ontology-explorer": "סייר אונטולוגיה", "ontology-health": "מצב האונטולוגיה", "ontology-inferred": "אונטולוגיה · מוסק", + "ontology-iri": "IRI של האונטולוגיה", "ontology-library": "ספריית אונטולוגיות", "ontology-relation-plural": "קשרי Ontology", + "ontology-release": "גרסת אונטולוגיה", "ontology-studio": "סטודיו אונטולוגיה", "open": "פתוח", "open-entity": "פתח {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "מושך", "properties": "מאפיינים", "property": "מאפיין", + "property-chain": "שרשרת מאפיינים", + "property-domain": "מחלקות תחום", + "property-iri": "IRI של המאפיין", "property-plural": "Properties", "propose-term": "הצע מונח", "proposed-by": "הוצע על ידי", @@ -2773,6 +2783,7 @@ "reposition": "שנה מיקום", "repository-url": "כתובת URL של המאגר", "request": "בקשה", + "request-approval": "בקשת אישור", "request-created-by": "בקשה נוצרה על ידי", "request-data-access": "בקש גישה לנתונים", "request-method": "שיטת בקשה", @@ -2957,6 +2968,7 @@ "schema-text": "טקסט סכמה", "schema-type": "סוג סכמה", "schema-validation": "אימות סכימה", + "scheme-iri": "IRI של סכמת המושגים", "scim-token": "אסימון SCIM", "scope": "היקף", "scope-and-behaviour": "היקף והתנהגות", @@ -3277,6 +3289,7 @@ "submit-review": "שלח סקירה", "submit-review-count": "שלח סקירה ({{count}})", "submitted-by": "הוגש על ידי", + "submitted-definition": "ההגדרה שנשלחה", "submitted-on": "הוגש ב", "submitting": "שולח", "subscription": "מינוי", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "הוסיפו לפחות שני מושגים וסוג קשר אחד לפני בקשת הצעות.", "ontology-ai-select-glossary": "בחרו מילון מונחים אחד כדי להגדיר את היקף הצעות הבינה המלאכותית וההרשאה.", "ontology-ai-standards-placeholder": "הזינו תקנים מופרדים בפסיקים, לדוגמה FIBO,‏ FHIR,‏ schema.org", + "ontology-consistency-not-run": "העקביות הלוגית של גרסה זו טרם נבדקה.", "ontology-edit-held-by": "{{holder}} עורך את המילון הזה בהפעלה אחרת. סגרו את ההפעלה או המתינו דקה עד שתפוג, ונסו שוב.", "ontology-empty-description": "אונטולוגיה לוכדת את הקשרים בין המונחים שלך — מה שייך למה, מה נגזר ממה — כך שאוצר המילים שלך הופך למודל מקושר של העסק שלך, ולא רק לרשימה אלפביתית.", "ontology-empty-title": "הפוך את מילון המונחים שלך למפה של האופן שבו מושגים מתחברים", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — מחלקות ותכונות אובייקט", "ontology-format-skos-description": "סכמת מושגים עם תוויות ומיפויים", "ontology-graph-feature-description": "כל קישור משורטט כאן — מפה חיה של האופן שבו המושגים שלך משתלבים יחד.", + "ontology-graph-limit": "הגרף מציג עד {{count}} מושגים. עבור אונטולוגיות גדולות יותר, השתמשו בדפדפן המודל.", "ontology-import-issues-count": "{{count}} פריטים דולגו או דורשים בדיקה", "ontology-import-summary": "נמצאו {{terms}} מונחים, {{relations}} קשרים, {{mappings}} מיפויי מושגים ו-{{properties}} מאפיינים מותאמים אישית.", "ontology-imported-successfully": "האונטולוגיה יובאה בהצלחה", + "ontology-iri-per-line": "IRI אחד בכל שורה; סדר שרשרת המאפיינים נשמר.", "ontology-library-description": "אונטולוגיות מוכנות מראש התואמות לתקנים. התקינו כפי שהן, או בחרו תחילה מודולים.", "ontology-library-load-error": "לא ניתן לטעון את ספריית האונטולוגיות.", "ontology-not-connected-description": "למונחים אלה אין עדיין קשרים. חברו אותם כדי למקם אותם באונטולוגיה.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "אחאים · שניהם תחת {{term}}", "ontology-relation-subtype": "{{from}} הוא {{to}}", "ontology-relationships-feature-description": "קשר מונחים במשמעות: \"קשור ל\", \"חלק מ\" או סוגים משלך כמו \"מחושב מ\".", + "ontology-review-unavailable": "לא ניתן לטעון את הגרסה שנשלחה. ייתכן שהיא אינה זמינה או שאין לך הרשאה לקרוא אותה.", "ontology-studio-description": "מדלו מושגים עסקיים ואת הקשרים ביניהם.", "ontology-studio-edit-coming-soon": "עריכה חזותית של קשרים תגיע בקרוב. בינתיים, חקרו את גרף האונטולוגיה או הריצו שאילתות SPARQL.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/it-it.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/it-it.json index fc61562f522c..5044b10954aa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/it-it.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/it-it.json @@ -37,6 +37,7 @@ "action": "Azione", "action-plural": "Azioni", "action-required": "Azione Richiesta", + "activate-release": "Attiva versione", "active": "Attivo", "active-persona": "Persona attiva", "active-recently": "Attivo di recente", @@ -434,6 +435,7 @@ "caption-expected-value": "atteso {{value}}", "caption-learned-range": "intervallo appreso (auto)", "caption-threshold": "soglia {{value}}", + "capture-release": "Acquisisci versione", "card": "Scheda", "cardinality": "Cardinalità", "cascade": "Cascata", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "Applicazioni Disabilitate", "discard": "Scarta", "discover": "Scopri", + "disjoint-properties": "Proprietà disgiunte", "dismiss": "Ignora", "display-name": "Nome Visualizzato", "display-name-lowercase": "nome visualizzato", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} volte", "n-times_few": "{{count}} volte", "n-times_one": "{{count}} volta", + "n-triples-nt": "N-Triples (.nt)", "name": "Nome", "name-is-required": "Il nome è obbligatorio", "name-lowercase": "nome", "name-this-ingestion": "Dai un nome a questa ingestion", "name-this-service": "Dai un nome a questo servizio", + "namespace": "Namespace", "narrow-match": "Corrispondenza più specifica", "narrower": "Più Specifico", "native": "Nativo", @@ -2342,8 +2347,10 @@ "ontology-explorer": "Esplora Ontologia", "ontology-health": "Salute dell'ontologia", "ontology-inferred": "Ontologia · dedotta", + "ontology-iri": "IRI dell'ontologia", "ontology-library": "Libreria di Ontologie", "ontology-relation-plural": "Relazioni dell'ontologia", + "ontology-release": "Versione dell'ontologia", "ontology-studio": "Ontology Studio", "open": "Apri", "open-entity": "Apri {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "Propagato", "properties": "Proprietà", "property": "Proprietà", + "property-chain": "Catena di proprietà", + "property-domain": "Classi del dominio", + "property-iri": "IRI della proprietà", "property-plural": "Proprietà", "propose-term": "Proponi Termine", "proposed-by": "Proposto da", @@ -2773,6 +2783,7 @@ "reposition": "Riposiziona", "repository-url": "URL del Repository", "request": "Richiesta", + "request-approval": "Richiedi approvazione", "request-created-by": "Richiesta creata da", "request-data-access": "Richiedi Accesso ai Dati", "request-method": "Metodo della Richiesta", @@ -2957,6 +2968,7 @@ "schema-text": "Testo dello Schema", "schema-type": "Tipo di Schema", "schema-validation": "Validazione dello Schema", + "scheme-iri": "IRI dello schema", "scim-token": "Token SCIM", "scope": "Ambito", "scope-and-behaviour": "Ambito e Comportamento", @@ -3277,6 +3289,7 @@ "submit-review": "Invia revisione", "submit-review-count": "Invia revisione ({{count}})", "submitted-by": "Inviato Da", + "submitted-definition": "Definizione inviata", "submitted-on": "Inviato Il", "submitting": "Invio in corso", "subscription": "Sottoscrizione", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "Aggiungi almeno due concetti e un tipo di relazione prima di richiedere proposte.", "ontology-ai-select-glossary": "Seleziona un glossario per definire l'ambito delle proposte AI e delle autorizzazioni.", "ontology-ai-standards-placeholder": "Inserisci gli standard separati da virgole, ad esempio FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "La coerenza logica non è stata verificata per questa versione.", "ontology-edit-held-by": "{{holder}} sta modificando questo glossario in un'altra sessione. Chiudi quella sessione o attendi un minuto che scada, quindi riprova.", "ontology-empty-description": "Un'ontologia cattura le relazioni tra i tuoi termini — cosa appartiene a cosa, cosa deriva da cosa — così il tuo vocabolario diventa un modello connesso del tuo business, non solo un elenco alfabetico.", "ontology-empty-title": "Trasforma il tuo glossario in una mappa di come si collegano i concetti", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — classi e proprietà di oggetto", "ontology-format-skos-description": "Schema di concetti con etichette e mappature", "ontology-graph-feature-description": "Ogni collegamento si disegna da solo qui — una mappa viva di come i tuoi concetti si incastrano.", + "ontology-graph-limit": "Il grafo visualizza fino a {{count}} concetti. Per ontologie più grandi, usa il browser del modello.", "ontology-import-issues-count": "{{count}} elemento/i sono stati saltati o vanno rivisti", "ontology-import-summary": "Trovati {{terms}} termine/i, {{relations}} relazione/i, {{mappings}} mappatura/e di concetti e {{properties}} proprietà personalizzata/e.", "ontology-imported-successfully": "Ontologia importata correttamente", + "ontology-iri-per-line": "Un IRI per riga; l'ordine della catena di proprietà viene mantenuto.", "ontology-library-description": "Ontologie predefinite e allineate agli standard — installale subito, oppure personalizzale e installale.", "ontology-library-load-error": "Non è stato possibile caricare la libreria delle ontologie.", "ontology-not-connected-description": "Questi termini non hanno ancora relazioni. Collegali per collocarli nell'ontologia.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "Fratelli · entrambi sotto {{term}}", "ontology-relation-subtype": "{{from}} è un {{to}}", "ontology-relationships-feature-description": "Collega i termini con significati come \"Correlato a\", \"Parte di\" o con tipi tuoi come \"calcolato da\".", + "ontology-review-unavailable": "Impossibile caricare la versione inviata. Potrebbe non essere disponibile oppure potresti non avere il permesso di leggerla.", "ontology-studio-description": "Modella i concetti di business e le loro relazioni.", "ontology-studio-edit-coming-soon": "La creazione visuale delle relazioni arriverà presto. Per ora esplora il grafo dell'ontologia o esegui query SPARQL.", "ontology-tree-description": "Concetti dei glossari selezionati, organizzati per gerarchia. I termini isolati e poligerarchici sono evidenziati.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 2b157ed98d30..1258589fd3a2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -37,6 +37,7 @@ "action": "操作", "action-plural": "アクション", "action-required": "対応が必要です", + "activate-release": "リリースを有効化", "active": "アクティブ", "active-persona": "アクティブなペルソナ", "active-recently": "最近アクティブ", @@ -434,6 +435,7 @@ "caption-expected-value": "期待値 {{value}}", "caption-learned-range": "学習済み範囲 (自動)", "caption-threshold": "しきい値 {{value}}", + "capture-release": "リリースのスナップショットを作成", "card": "カード", "cardinality": "カーディナリティ", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "無効なアプリケーション", "discard": "破棄", "discover": "探索", + "disjoint-properties": "互いに素なプロパティ", "dismiss": "却下", "display-name": "表示名", "display-name-lowercase": "表示名", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} 回", "n-times_few": "{{count}} 回", "n-times_one": "{{count}} 回", + "n-triples-nt": "N-Triples (.nt)", "name": "名前", "name-is-required": "名前は必須です", "name-lowercase": "名前", "name-this-ingestion": "このインジェストを命名する", "name-this-service": "Name this service", + "namespace": "名前空間", "narrow-match": "下位一致", "narrower": "下位", "native": "ネイティブ", @@ -2342,8 +2347,10 @@ "ontology-explorer": "オントロジーエクスプローラー", "ontology-health": "オントロジーの健全性", "ontology-inferred": "オントロジー・推論", + "ontology-iri": "オントロジーのIRI", "ontology-library": "オントロジーライブラリ", "ontology-relation-plural": "Ontology の関係", + "ontology-release": "オントロジーのリリース", "ontology-studio": "オントロジースタジオ", "open": "開く", "open-entity": "{{entity}}を開く", @@ -2596,6 +2603,9 @@ "propagated": "伝播済み", "properties": "プロパティ", "property": "プロパティ", + "property-chain": "プロパティチェーン", + "property-domain": "定義域のクラス", + "property-iri": "プロパティのIRI", "property-plural": "Properties", "propose-term": "用語を提案", "proposed-by": "提案者", @@ -2773,6 +2783,7 @@ "reposition": "位置変更", "repository-url": "リポジトリURL", "request": "リクエスト", + "request-approval": "承認を申請", "request-created-by": "リクエスト作成者", "request-data-access": "データアクセスを要求", "request-method": "リクエストメソッド", @@ -2957,6 +2968,7 @@ "schema-text": "スキーマテキスト", "schema-type": "スキーマタイプ", "schema-validation": "スキーマ検証", + "scheme-iri": "概念体系のIRI", "scim-token": "SCIMトークン", "scope": "スコープ", "scope-and-behaviour": "スコープと動作", @@ -3277,6 +3289,7 @@ "submit-review": "レビューを送信", "submit-review-count": "レビューを送信 ({{count}})", "submitted-by": "送信者", + "submitted-definition": "提出された定義", "submitted-on": "送信日", "submitting": "送信中", "subscription": "サブスクリプション", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "提案を依頼する前に、少なくとも 2 つの概念と 1 つの関係タイプを追加してください。", "ontology-ai-select-glossary": "AI 提案と認可の範囲を限定する用語集を 1 つ選択してください。", "ontology-ai-standards-placeholder": "標準をカンマ区切りで入力してください(例: FIBO, FHIR, schema.org)", + "ontology-consistency-not-run": "このリリースの論理的整合性は確認されていません。", "ontology-edit-held-by": "{{holder}} が別のセッションでこの用語集を編集しています。そのセッションを閉じるか、期限切れになるまで 1 分ほど待ってから再試行してください。", "ontology-empty-description": "オントロジーは用語間の関係(何が何に属し、何が何から派生するか)を捉え、語彙を単なる五十音順のリストではなく、ビジネスのつながったモデルにします。", "ontology-empty-title": "用語集を、概念のつながりを示すマップに変えましょう", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — クラスとオブジェクトプロパティ", "ontology-format-skos-description": "ラベルとマッピングを持つ概念スキーム", "ontology-graph-feature-description": "すべてのリンクがここに描かれます。概念がどのように組み合わさるかを示す生きたマップです。", + "ontology-graph-limit": "グラフには最大 {{count}} 個の概念が表示されます。より大きなオントロジーにはモデルブラウザーを使用してください。", "ontology-import-issues-count": "{{count}} 件の項目がスキップされたか、確認が必要です", "ontology-import-summary": "{{terms}} 件の用語、{{relations}} 件の関係、{{mappings}} 件の概念マッピング、{{properties}} 件のカスタムプロパティが見つかりました。", "ontology-imported-successfully": "オントロジーが正常にインポートされました", + "ontology-iri-per-line": "1行に1つのIRIを入力してください。プロパティチェーンの順序は保持されます。", "ontology-library-description": "標準に準拠した構築済みオントロジー。そのままインストールするか、先にモジュールを選択してください。", "ontology-library-load-error": "オントロジーライブラリを読み込めませんでした。", "ontology-not-connected-description": "これらの用語にはまだリレーションがありません。接続してオントロジーに配置してください。", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "兄弟 · 両方とも {{term}} の下", "ontology-relation-subtype": "{{from}} は {{to}} の一種", "ontology-relationships-feature-description": "「関連する」「〜の一部」など意味を持たせて用語をリンクしたり、「〜から算出」のような独自のタイプを作成できます。", + "ontology-review-unavailable": "提出されたバージョンを読み込めませんでした。利用できないか、読み取り権限がない可能性があります。", "ontology-studio-description": "ビジネス概念とその関係をモデル化します。", "ontology-studio-edit-coming-soon": "リレーションシップのビジュアル編集は近日公開予定です。当面は、オントロジーグラフを探索するか、SPARQLクエリを実行してください。", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json index 000be5f3952b..50ac47287c2a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json @@ -37,6 +37,7 @@ "action": "작업", "action-plural": "작업들", "action-required": "조치 필요", + "activate-release": "릴리스 활성화", "active": "활성", "active-persona": "활성 페르소나", "active-recently": "최근 활성", @@ -434,6 +435,7 @@ "caption-expected-value": "예상값 {{value}}", "caption-learned-range": "학습된 범위 (자동)", "caption-threshold": "임계값 {{value}}", + "capture-release": "릴리스 스냅샷 생성", "card": "카드", "cardinality": "카디널리티", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "비활성화된 애플리케이션", "discard": "폐기", "discover": "발견", + "disjoint-properties": "서로소 속성", "dismiss": "무시", "display-name": "표시 이름", "display-name-lowercase": "표시 이름", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} 번", "n-times_few": "{{count}} 번", "n-times_one": "{{count}} 번", + "n-triples-nt": "N-Triples (.nt)", "name": "이름", "name-is-required": "이름은 필수입니다", "name-lowercase": "이름", "name-this-ingestion": "이 수집에 이름 붙이기", "name-this-service": "Name this service", + "namespace": "네임스페이스", "narrow-match": "협의 일치", "narrower": "하위", "native": "네이티브", @@ -2342,8 +2347,10 @@ "ontology-explorer": "온톨로지 탐색기", "ontology-health": "온톨로지 상태", "ontology-inferred": "온톨로지 · 추론됨", + "ontology-iri": "온톨로지 IRI", "ontology-library": "온톨로지 라이브러리", "ontology-relation-plural": "Ontology 관계", + "ontology-release": "온톨로지 릴리스", "ontology-studio": "온톨로지 스튜디오", "open": "열기", "open-entity": "{{entity}} 열기", @@ -2596,6 +2603,9 @@ "propagated": "전파됨", "properties": "속성", "property": "속성", + "property-chain": "속성 체인", + "property-domain": "정의역 클래스", + "property-iri": "속성 IRI", "property-plural": "Properties", "propose-term": "용어 제안", "proposed-by": "제안자", @@ -2773,6 +2783,7 @@ "reposition": "위치 변경", "repository-url": "저장소 URL", "request": "요청", + "request-approval": "승인 요청", "request-created-by": "Request created by", "request-data-access": "데이터 접근 요청", "request-method": "요청 메서드", @@ -2957,6 +2968,7 @@ "schema-text": "스키마 텍스트", "schema-type": "스키마 유형", "schema-validation": "스키마 검증", + "scheme-iri": "개념 체계 IRI", "scim-token": "SCIM 토큰", "scope": "범위", "scope-and-behaviour": "범위 및 동작", @@ -3277,6 +3289,7 @@ "submit-review": "검토 제출", "submit-review-count": "검토 제출 ({{count}})", "submitted-by": "제출자", + "submitted-definition": "제출된 정의", "submitted-on": "제출일", "submitting": "제출 중", "subscription": "구독", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "제안을 요청하기 전에 개념 두 개 이상과 관계 유형 하나를 추가하세요.", "ontology-ai-select-glossary": "AI 제안과 권한 부여 범위를 지정할 용어집 하나를 선택하세요.", "ontology-ai-standards-placeholder": "쉼표로 구분된 표준을 입력하세요. 예: FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "이 릴리스의 논리적 일관성은 확인되지 않았습니다.", "ontology-edit-held-by": "{{holder}}님이 다른 세션에서 이 용어집을 편집하고 있습니다. 해당 세션을 닫거나 만료될 때까지 1분 정도 기다린 후 다시 시도하세요.", "ontology-empty-description": "온톨로지는 용어 간의 관계(무엇이 무엇에 속하고, 무엇이 무엇에서 파생되는지)를 포착하여 어휘를 단순한 알파벳순 목록이 아니라 비즈니스의 연결된 모델로 만듭니다.", "ontology-empty-title": "용어집을 개념이 연결되는 방식의 지도로 바꿔보세요", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — 클래스 및 객체 속성", "ontology-format-skos-description": "레이블과 매핑이 있는 개념 스킴", "ontology-graph-feature-description": "모든 연결이 여기에 그려집니다. 개념이 어떻게 어우러지는지 보여주는 살아있는 지도입니다.", + "ontology-graph-limit": "그래프는 최대 {{count}}개의 개념을 표시합니다. 더 큰 온톨로지는 모델 브라우저를 사용하세요.", "ontology-import-issues-count": "{{count}}개의 항목을 건너뛰었거나 검토가 필요합니다", "ontology-import-summary": "{{terms}}개의 용어, {{relations}}개의 관계, {{mappings}}개의 개념 매핑, {{properties}}개의 사용자 지정 속성을 찾았습니다.", "ontology-imported-successfully": "온톨로지를 성공적으로 가져왔습니다", + "ontology-iri-per-line": "한 줄에 하나의 IRI를 입력하세요. 속성 체인 순서는 유지됩니다.", "ontology-library-description": "표준에 맞춘 사전 구축 온톨로지입니다. 그대로 설치하거나 먼저 모듈을 선택하세요.", "ontology-library-load-error": "온톨로지 라이브러리를 불러올 수 없습니다.", "ontology-not-connected-description": "이 용어들에는 아직 관계가 없습니다. 연결하여 온톨로지에 배치하세요.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "형제 · 둘 다 {{term}} 아래", "ontology-relation-subtype": "{{from}}은(는) {{to}}의 일종", "ontology-relationships-feature-description": "\"관련됨\", \"~의 일부\"와 같은 의미로 용어를 연결하거나 \"~에서 계산됨\"과 같은 사용자 지정 유형을 만드세요.", + "ontology-review-unavailable": "제출된 버전을 불러올 수 없습니다. 사용할 수 없거나 읽기 권한이 없을 수 있습니다.", "ontology-studio-description": "비즈니스 개념과 그 관계를 모델링합니다.", "ontology-studio-edit-coming-soon": "관계 시각 편집 기능이 곧 제공됩니다. 지금은 온톨로지 그래프를 탐색하거나 SPARQL 쿼리를 실행하세요.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index ec3acf511eaf..8045b9a0a2e3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -37,6 +37,7 @@ "action": "कृती", "action-plural": "कृती", "action-required": "कृती आवश्यक आहे", + "activate-release": "प्रकाशन सक्रिय करा", "active": "सक्रिय", "active-persona": "सक्रिय व्यक्तिमत्व", "active-recently": "अलीकडे सक्रिय", @@ -434,6 +435,7 @@ "caption-expected-value": "अपेक्षित {{value}}", "caption-learned-range": "शिकलेली श्रेणी (स्वयं)", "caption-threshold": "उंबरठा {{value}}", + "capture-release": "प्रकाशनाची प्रतिमा तयार करा", "card": "कार्ड", "cardinality": "कार्डिनलिटी", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "अक्षम अनुप्रयोग", "discard": "टाका", "discover": "शोधा", + "disjoint-properties": "परस्पर विसंगत गुणधर्म", "dismiss": "नाकारून टाका", "display-name": "प्रदर्शन नाव", "display-name-lowercase": "प्रदर्शन नाव", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} वेळा", "n-times_few": "{{count}} वेळा", "n-times_one": "{{count}} वेळ", + "n-triples-nt": "N-Triples (.nt)", "name": "नाव", "name-is-required": "नाव आवश्यक आहे", "name-lowercase": "नाव", "name-this-ingestion": "हे अंतर्ग्रहण नाव द्या", "name-this-service": "Name this service", + "namespace": "नामविश्व", "narrow-match": "अरुंद जुळणी", "narrower": "संकुचित", "native": "मूळ", @@ -2342,8 +2347,10 @@ "ontology-explorer": "ऑन्टोलॉजी एक्सप्लोरर", "ontology-health": "ऑन्टोलॉजीचे स्वास्थ्य", "ontology-inferred": "ऑन्टोलॉजी · अनुमानित", + "ontology-iri": "ऑन्टोलॉजी IRI", "ontology-library": "ऑन्टोलॉजी लायब्ररी", "ontology-relation-plural": "Ontology संबंध", + "ontology-release": "ऑन्टोलॉजी प्रकाशन", "ontology-studio": "ऑन्टोलॉजी स्टुडिओ", "open": "उघडा", "open-entity": "{{entity}} उघडा", @@ -2596,6 +2603,9 @@ "propagated": "प्रसारित", "properties": "गुणधर्म", "property": "गुणधर्म", + "property-chain": "गुणधर्म साखळी", + "property-domain": "डोमेन वर्ग", + "property-iri": "गुणधर्म IRI", "property-plural": "Properties", "propose-term": "संज्ञा सुचवा", "proposed-by": "प्रस्तावक", @@ -2773,6 +2783,7 @@ "reposition": "स्थिती बदला", "repository-url": "रेपॉझिटरी URL", "request": "विनंती", + "request-approval": "मंजुरीची विनंती करा", "request-created-by": "Request created by", "request-data-access": "डेटा प्रवेशाची विनंती करा", "request-method": "विनंती पद्धत", @@ -2957,6 +2968,7 @@ "schema-text": "योजना मजकूर", "schema-type": "योजना प्रकार", "schema-validation": "स्कीमा प्रमाणीकरण", + "scheme-iri": "संकल्पना योजनेचा IRI", "scim-token": "SCIM टोकन", "scope": "व्याप्ती", "scope-and-behaviour": "व्याप्ती आणि वर्तन", @@ -3277,6 +3289,7 @@ "submit-review": "पुनरावलोकन प्रस्तुत करा", "submit-review-count": "पुनरावलोकन प्रस्तुत करा ({{count}})", "submitted-by": "द्वारे सबमिट केले", + "submitted-definition": "सादर केलेली व्याख्या", "submitted-on": "सबमिट केले", "submitting": "सबमिट करत आहे", "subscription": "सदस्यता", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "प्रस्ताव मागण्यापूर्वी किमान दोन संकल्पना आणि एक संबंध प्रकार जोडा.", "ontology-ai-select-glossary": "AI प्रस्ताव आणि अधिकृततेची व्याप्ती ठरवण्यासाठी एक शब्दकोश निवडा.", "ontology-ai-standards-placeholder": "स्वल्पविरामाने विभक्त मानके प्रविष्ट करा, उदाहरणार्थ FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "या प्रकाशनाची तार्किक सुसंगतता तपासलेली नाही.", "ontology-edit-held-by": "{{holder}} दुसऱ्या सत्रात ही शब्दसूची संपादित करत आहेत. ते सत्र बंद करा किंवा ते कालबाह्य होण्यासाठी एक मिनिट थांबा, नंतर पुन्हा प्रयत्न करा.", "ontology-empty-description": "ऑन्टोलॉजी तुमच्या संज्ञांमधील संबंध टिपते — काय कशाशी संबंधित आहे, काय कशापासून निर्माण होते — जेणेकरून तुमचा शब्दसंग्रह केवळ वर्णानुक्रमे यादी नसून तुमच्या व्यवसायाचे जोडलेले मॉडेल बनतो.", "ontology-empty-title": "तुमचा शब्दकोश संकल्पना कशा जोडल्या जातात याच्या नकाशात रूपांतरित करा", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — वर्ग आणि ऑब्जेक्ट गुणधर्म", "ontology-format-skos-description": "लेबल आणि मॅपिंगसह संकल्पना योजना", "ontology-graph-feature-description": "प्रत्येक दुवा येथे रेखाटला जातो — तुमच्या संकल्पना कशा एकत्र येतात याचा जिवंत नकाशा.", + "ontology-graph-limit": "आलेखामध्ये जास्तीत जास्त {{count}} संकल्पना दिसतात. मोठ्या ऑन्टोलॉजींसाठी मॉडेल ब्राउझर वापरा.", "ontology-import-issues-count": "{{count}} घटक वगळले गेले किंवा त्यांचे पुनरावलोकन आवश्यक आहे", "ontology-import-summary": "{{terms}} संज्ञा, {{relations}} संबंध, {{mappings}} संकल्पना मॅपिंग आणि {{properties}} सानुकूल गुणधर्म आढळले.", "ontology-imported-successfully": "ऑन्टोलॉजी यशस्वीरित्या आयात केली", + "ontology-iri-per-line": "प्रत्येक ओळीत एक IRI; गुणधर्म साखळीचा क्रम जतन केला जातो.", "ontology-library-description": "पूर्व-निर्मित, मानकांशी सुसंगत ऑन्टोलॉजी. जशास तसे इंस्टॉल करा, किंवा आधी मॉड्यूल निवडा.", "ontology-library-load-error": "ऑन्टोलॉजी लायब्ररी लोड करता आली नाही.", "ontology-not-connected-description": "या संज्ञांना अद्याप कोणतेही संबंध नाहीत. त्यांना ऑन्टोलॉजीमध्ये स्थान देण्यासाठी जोडा.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "भावंडे · दोन्ही {{term}} अंतर्गत", "ontology-relation-subtype": "{{from}} हा एक {{to}} आहे", "ontology-relationships-feature-description": "अर्थासह संज्ञा जोडा: \"शी संबंधित\", \"चा भाग\" किंवा \"पासून गणना केलेले\" यासारखे तुमचे स्वतःचे प्रकार.", + "ontology-review-unavailable": "सादर केलेली आवृत्ती लोड करता आली नाही. ती उपलब्ध नसू शकते किंवा तुम्हाला ती वाचण्याची परवानगी नसू शकते.", "ontology-studio-description": "व्यवसाय संकल्पना आणि त्यांचे परस्परसंबंध मॉडेल करा.", "ontology-studio-edit-coming-soon": "व्हिज्युअल रिलेशनशिप संपादन लवकरच येत आहे. सध्या, ऑन्टोलॉजी आलेख एक्सप्लोर करा किंवा SPARQL क्वेरी चालवा.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index ae6b6528caed..3fdb37d52693 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -37,6 +37,7 @@ "action": "Actie", "action-plural": "Acties", "action-required": "Actie vereist", + "activate-release": "Release activeren", "active": "Actief", "active-persona": "Actieve persona", "active-recently": "Recent actief", @@ -434,6 +435,7 @@ "caption-expected-value": "verwacht {{value}}", "caption-learned-range": "geleerd bereik (auto)", "caption-threshold": "drempel {{value}}", + "capture-release": "Release vastleggen", "card": "Kaart", "cardinality": "Kardinaliteit", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "Uitgeschakelde applicaties", "discard": "Weggooien", "discover": "Ontdekken", + "disjoint-properties": "Disjuncte eigenschappen", "dismiss": "Negeren", "display-name": "Weergavenaam", "display-name-lowercase": "weergavenaam", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} keer", "n-times_few": "{{count}} keer", "n-times_one": "{{count}} keer", + "n-triples-nt": "N-Triples (.nt)", "name": "Naam", "name-is-required": "Naam is verplicht", "name-lowercase": "naam", "name-this-ingestion": "Geef dit ingestion een naam", "name-this-service": "Name this service", + "namespace": "Naamruimte", "narrow-match": "Specifieke overeenkomst", "narrower": "Nauwer", "native": "Native", @@ -2342,8 +2347,10 @@ "ontology-explorer": "Ontologie-verkenner", "ontology-health": "Ontologiestatus", "ontology-inferred": "Ontologie · afgeleid", + "ontology-iri": "Ontologie-IRI", "ontology-library": "Ontologiebibliotheek", "ontology-relation-plural": "Ontology-relaties", + "ontology-release": "Ontologierelease", "ontology-studio": "Ontologie-studio", "open": "Openen", "open-entity": "{{entity}} openen", @@ -2596,6 +2603,9 @@ "propagated": "Geëxtend", "properties": "Eigenschappen", "property": "Eigenschap", + "property-chain": "Eigenschappenketen", + "property-domain": "Domeinklassen", + "property-iri": "Eigenschap-IRI", "property-plural": "Properties", "propose-term": "Term voorstellen", "proposed-by": "Voorgesteld door", @@ -2773,6 +2783,7 @@ "reposition": "Herpositioneren", "repository-url": "Repository-URL", "request": "Verzoek", + "request-approval": "Goedkeuring aanvragen", "request-created-by": "Verzoek gemaakt door", "request-data-access": "Gegevenstoegang aanvragen", "request-method": "Requestmethode", @@ -2957,6 +2968,7 @@ "schema-text": "Schematekst", "schema-type": "Schematype", "schema-validation": "Schema-validatie", + "scheme-iri": "IRI van het conceptschema", "scim-token": "SCIM-token", "scope": "Bereik", "scope-and-behaviour": "Bereik en gedrag", @@ -3277,6 +3289,7 @@ "submit-review": "Beoordeling indienen", "submit-review-count": "Beoordeling indienen ({{count}})", "submitted-by": "Ingediend door", + "submitted-definition": "Ingediende definitie", "submitted-on": "Ingediend op", "submitting": "Bezig met verzenden", "subscription": "Abonnement", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "Voeg ten minste twee concepten en één relatietype toe voordat u voorstellen aanvraagt.", "ontology-ai-select-glossary": "Selecteer één woordenlijst om AI-voorstellen en autorisatie af te bakenen.", "ontology-ai-standards-placeholder": "Voer door komma's gescheiden standaarden in, bijvoorbeeld FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "De logische consistentie van deze release is niet gecontroleerd.", "ontology-edit-held-by": "{{holder}} bewerkt deze woordenlijst in een andere sessie. Sluit die sessie of wacht een minuut tot deze verloopt en probeer het opnieuw.", "ontology-empty-description": "Een ontologie legt de relaties tussen je termen vast — wat bij wat hoort, wat waaruit voortkomt — zodat je vocabulaire een verbonden model van je bedrijf wordt, niet zomaar een alfabetische lijst.", "ontology-empty-title": "Maak van je woordenlijst een kaart van hoe concepten met elkaar verbonden zijn", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — klassen en objecteigenschappen", "ontology-format-skos-description": "Conceptschema met labels en toewijzingen", "ontology-graph-feature-description": "Elke verbinding tekent zich hier: een levende kaart van hoe je concepten samenkomen.", + "ontology-graph-limit": "De graaf toont maximaal {{count}} concepten. Gebruik de modelbrowser voor grotere ontologieën.", "ontology-import-issues-count": "{{count}} item(s) zijn overgeslagen of moeten worden gecontroleerd", "ontology-import-summary": "{{terms}} term(en), {{relations}} relatie(s), {{mappings}} conceptkoppeling(en) en {{properties}} aangepaste eigenschap(pen) gevonden.", "ontology-imported-successfully": "Ontologie succesvol geïmporteerd", + "ontology-iri-per-line": "Eén IRI per regel; de volgorde van de eigenschappenketen blijft behouden.", "ontology-library-description": "Kant-en-klare, op standaarden afgestemde ontologieën. Installeer ze zoals ze zijn, of kies eerst modules.", "ontology-library-load-error": "De ontologiebibliotheek kon niet worden geladen.", "ontology-not-connected-description": "Deze termen hebben nog geen relaties. Verbind ze om ze in de ontologie te plaatsen.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "Broers/zussen · beide onder {{term}}", "ontology-relation-subtype": "{{from}} is een {{to}}", "ontology-relationships-feature-description": "Koppel termen met betekenis: \"Gerelateerd aan\", \"Onderdeel van\" of je eigen typen zoals \"berekend uit\".", + "ontology-review-unavailable": "De ingediende versie kon niet worden geladen. Deze is mogelijk niet beschikbaar of u hebt geen toestemming om deze te lezen.", "ontology-studio-description": "Modelleer bedrijfsconcepten en hoe ze met elkaar samenhangen.", "ontology-studio-edit-coming-soon": "Visueel relaties bewerken komt binnenkort. Verken voorlopig de ontologiegrafiek of voer SPARQL-query's uit.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index 479409325c33..4841a4414187 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -37,6 +37,7 @@ "action": "عمل", "action-plural": "اعمال", "action-required": "Acción requerida", + "activate-release": "فعال‌سازی انتشار", "active": "فعال", "active-persona": "پرسونای فعال", "active-recently": "به تازگی فعال", @@ -434,6 +435,7 @@ "caption-expected-value": "مورد انتظار {{value}}", "caption-learned-range": "بازهٔ آموخته‌شده (خودکار)", "caption-threshold": "آستانه {{value}}", + "capture-release": "ثبت نسخه انتشار", "card": "کارت", "cardinality": "تعداد عناصر", "cascade": "آبشاری", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "برنامه‌های غیرفعال", "discard": "رد کرنا", "discover": "کشف", + "disjoint-properties": "ویژگی‌های مجزا", "dismiss": "رد کردن", "display-name": "نام نمایشی", "display-name-lowercase": "نام نمایشی", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} بار", "n-times_few": "{{count}} بار", "n-times_one": "{{count}} بار", + "n-triples-nt": "N-Triples (.nt)", "name": "نام", "name-is-required": "نام الزامی است", "name-lowercase": "نام", "name-this-ingestion": "Nomear esta ingestão", "name-this-service": "این سرویس را نام‌گذاری کنید", + "namespace": "فضای نام", "narrow-match": "تطابق باریک", "narrower": "محدودتر", "native": "بومی", @@ -2342,8 +2347,10 @@ "ontology-explorer": "کاوشگر هستی‌شناسی", "ontology-health": "سلامت هستی‌شناسی", "ontology-inferred": "هستان‌شناسی · استنتاج‌شده", + "ontology-iri": "IRI هستی‌شناسی", "ontology-library": "کتابخانه هستی‌شناسی", "ontology-relation-plural": "روابط Ontology", + "ontology-release": "نسخه هستی‌شناسی", "ontology-studio": "استودیو هستی‌شناسی", "open": "باز", "open-entity": "باز کردن {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "پراگت", "properties": "ویژگی‌ها", "property": "ویژگی", + "property-chain": "زنجیرهٔ ویژگی‌ها", + "property-domain": "کلاس‌های دامنه", + "property-iri": "IRI ویژگی", "property-plural": "Properties", "propose-term": "پیشنهاد اصطلاح", "proposed-by": "پیشنهادشده توسط", @@ -2773,6 +2783,7 @@ "reposition": "تغییر موقعیت", "repository-url": "آدرس مخزن", "request": "درخواست", + "request-approval": "درخواست تأیید", "request-created-by": "درخواست ایجاد شده توسط", "request-data-access": "درخواست دسترسی به داده", "request-method": "روش درخواست", @@ -2957,6 +2968,7 @@ "schema-text": "متن اسکیما", "schema-type": "نوع شِما", "schema-validation": "Validação de esquema", + "scheme-iri": "IRI طرح مفهومی", "scim-token": "توکن SCIM", "scope": "محدوده", "scope-and-behaviour": "Âmbito e comportamento", @@ -3277,6 +3289,7 @@ "submit-review": "ارسال بررسی", "submit-review-count": "ارسال بررسی ({{count}})", "submitted-by": "ارسال شده توسط", + "submitted-definition": "تعریف ارسال‌شده", "submitted-on": "ارسال شده در", "submitting": "در حال ارسال", "subscription": "اشتراک", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "پیش از درخواست پیشنهاد، دست‌کم دو مفهوم و یک نوع رابطه اضافه کنید.", "ontology-ai-select-glossary": "یک واژه‌نامه را برای تعیین دامنه پیشنهادهای هوش مصنوعی و مجوز انتخاب کنید.", "ontology-ai-standards-placeholder": "استانداردها را با ویرگول جدا کنید، برای نمونه FIBO،‏ FHIR،‏ schema.org", + "ontology-consistency-not-run": "سازگاری منطقی این نسخه بررسی نشده است.", "ontology-edit-held-by": "{{holder}} در حال ویرایش این واژه‌نامه در نشست دیگری است. آن نشست را ببندید یا یک دقیقه صبر کنید تا منقضی شود و دوباره تلاش کنید.", "ontology-empty-description": "هستان‌شناسی روابط میان اصطلاحات شما را ثبت می‌کند — چه چیزی به چه چیزی تعلق دارد، چه چیزی از چه چیزی مشتق می‌شود — تا واژگان شما به مدلی به‌هم‌پیوسته از کسب‌وکارتان تبدیل شود، نه صرفاً یک فهرست الفبایی.", "ontology-empty-title": "واژه‌نامه خود را به نقشه‌ای از چگونگی ارتباط مفاهیم تبدیل کنید", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — کلاس‌ها و ویژگی‌های شیء", "ontology-format-skos-description": "طرحواره مفهومی با برچسب‌ها و نگاشت‌ها", "ontology-graph-feature-description": "هر پیوند در اینجا ترسیم می‌شود — نقشه‌ای زنده از چگونگی کنار هم قرار گرفتن مفاهیم شما.", + "ontology-graph-limit": "نمودار حداکثر {{count}} مفهوم را نمایش می‌دهد. برای هستی‌شناسی‌های بزرگ‌تر از مرورگر مدل استفاده کنید.", "ontology-import-issues-count": "{{count}} مورد نادیده گرفته شد یا نیاز به بررسی دارد", "ontology-import-summary": "{{terms}} اصطلاح، {{relations}} رابطه، {{mappings}} نگاشت مفهوم و {{properties}} ویژگی سفارشی یافت شد.", "ontology-imported-successfully": "آنتولوژی با موفقیت وارد شد", + "ontology-iri-per-line": "در هر خط یک IRI؛ ترتیب زنجیرهٔ ویژگی‌ها حفظ می‌شود.", "ontology-library-description": "هستی‌شناسی‌های از پیش ساخته و همسو با استانداردها. همان‌طور که هستند نصب کنید یا ابتدا ماژول‌ها را انتخاب کنید.", "ontology-library-load-error": "کتابخانه هستی‌شناسی بارگیری نشد.", "ontology-not-connected-description": "این اصطلاحات هنوز رابطه‌ای ندارند. آن‌ها را متصل کنید تا در هستی‌شناسی جای بگیرند.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "هم‌رده · هر دو زیرمجموعهٔ {{term}}", "ontology-relation-subtype": "{{from}} یک {{to}} است", "ontology-relationships-feature-description": "اصطلاحات را با معنا پیوند دهید: «مرتبط با»، «بخشی از» یا انواع دلخواه خودتان مانند «محاسبه‌شده از».", + "ontology-review-unavailable": "نسخه ارسال‌شده بارگیری نشد. ممکن است در دسترس نباشد یا مجوز خواندن آن را نداشته باشید.", "ontology-studio-description": "مفاهیم کسب‌وکار و نحوه ارتباط آن‌ها را مدل‌سازی کنید.", "ontology-studio-edit-coming-soon": "ویرایش بصری روابط به‌زودی ارائه می‌شود. فعلاً نمودار هستی‌شناسی را کاوش کنید یا پرس‌وجوهای SPARQL را اجرا کنید.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index e5c32ad29134..1c9eb82c810d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -37,6 +37,7 @@ "action": "Ação", "action-plural": "Ações", "action-required": "Ação necessária", + "activate-release": "Ativar versão", "active": "Ativo", "active-persona": "Persona ativa", "active-recently": "Ativo recentemente", @@ -434,6 +435,7 @@ "caption-expected-value": "esperado {{value}}", "caption-learned-range": "intervalo aprendido (auto)", "caption-threshold": "limite {{value}}", + "capture-release": "Capturar versão", "card": "Cartão", "cardinality": "Cardinalidade", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "Aplicativos desativados", "discard": "Descartar", "discover": "Descobrir", + "disjoint-properties": "Propriedades disjuntas", "dismiss": "Descartar", "display-name": "Nome de Exibição", "display-name-lowercase": "nome de exibição", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} vezes", "n-times_few": "{{count}} vezes", "n-times_one": "{{count}} vez", + "n-triples-nt": "N-Triples (.nt)", "name": "Nome", "name-is-required": "O nome é obrigatório", "name-lowercase": "nome", "name-this-ingestion": "Nomear esta ingestão", "name-this-service": "Name this service", + "namespace": "Espaço de nomes", "narrow-match": "Correspondência estreita", "narrower": "Mais específico", "native": "Nativo", @@ -2342,8 +2347,10 @@ "ontology-explorer": "Explorador de Ontologia", "ontology-health": "Integridade da ontologia", "ontology-inferred": "Ontologia · inferida", + "ontology-iri": "IRI da ontologia", "ontology-library": "Biblioteca de ontologias", "ontology-relation-plural": "Relações da Ontology", + "ontology-release": "Versão da ontologia", "ontology-studio": "Estúdio de Ontologia", "open": "Abrir", "open-entity": "Abrir {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "Propagado", "properties": "Propriedades", "property": "Propriedade", + "property-chain": "Cadeia de propriedades", + "property-domain": "Classes do domínio", + "property-iri": "IRI da propriedade", "property-plural": "Properties", "propose-term": "Propor termo", "proposed-by": "Proposto por", @@ -2773,6 +2783,7 @@ "reposition": "Reposicionar", "repository-url": "URL do Repositório", "request": "Solicitação", + "request-approval": "Solicitar aprovação", "request-created-by": "Solicitação criada por", "request-data-access": "Solicitar acesso a dados", "request-method": "Método de solicitação", @@ -2957,6 +2968,7 @@ "schema-text": "Texto do esquema", "schema-type": "Tipo de esquema", "schema-validation": "Validação de esquema", + "scheme-iri": "IRI do esquema de conceitos", "scim-token": "Token SCIM", "scope": "Escopo", "scope-and-behaviour": "Escopo e comportamento", @@ -3277,6 +3289,7 @@ "submit-review": "Enviar revisão", "submit-review-count": "Enviar revisão ({{count}})", "submitted-by": "Enviado por", + "submitted-definition": "Definição enviada", "submitted-on": "Enviado em", "submitting": "Enviando", "subscription": "Assinatura", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "Adicione pelo menos dois conceitos e um tipo de relacionamento antes de solicitar propostas.", "ontology-ai-select-glossary": "Selecione um glossário para delimitar as propostas de IA e a autorização.", "ontology-ai-standards-placeholder": "Insira padrões separados por vírgulas, por exemplo FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "A consistência lógica desta versão não foi verificada.", "ontology-edit-held-by": "{{holder}} está editando este glossário em outra sessão. Feche essa sessão ou aguarde um minuto até ela expirar e tente novamente.", "ontology-empty-description": "Uma ontologia captura as relações entre seus termos — o que pertence a quê, o que deriva de quê — para que seu vocabulário se torne um modelo conectado do seu negócio, não apenas uma lista alfabética.", "ontology-empty-title": "Transforme seu glossário em um mapa de como os conceitos se conectam", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — classes e propriedades de objeto", "ontology-format-skos-description": "Esquema de conceitos com rótulos e mapeamentos", "ontology-graph-feature-description": "Cada ligação se desenha aqui: um mapa vivo de como seus conceitos se encaixam.", + "ontology-graph-limit": "O grafo exibe até {{count}} conceitos. Use o explorador de modelos para ontologias maiores.", "ontology-import-issues-count": "{{count}} item(ns) foram ignorados ou precisam de revisão", "ontology-import-summary": "Encontrado(s) {{terms}} termo(s), {{relations}} relação(ões), {{mappings}} mapeamento(s) de conceito e {{properties}} propriedade(s) personalizada(s).", "ontology-imported-successfully": "Ontologia importada com sucesso", + "ontology-iri-per-line": "Um IRI por linha; a ordem da cadeia de propriedades é preservada.", "ontology-library-description": "Ontologias prontas e alinhadas a padrões. Instale como estão ou escolha os módulos primeiro.", "ontology-library-load-error": "Não foi possível carregar a biblioteca de ontologias.", "ontology-not-connected-description": "Esses termos ainda não têm relações. Conecte-os para posicioná-los na ontologia.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "Irmãos · ambos sob {{term}}", "ontology-relation-subtype": "{{from}} é um {{to}}", "ontology-relationships-feature-description": "Vincule termos com significado: \"Relacionado a\", \"Parte de\" ou seus próprios tipos como \"calculado a partir de\".", + "ontology-review-unavailable": "Não foi possível carregar a versão enviada. Ela pode estar indisponível ou você pode não ter permissão para lê-la.", "ontology-studio-description": "Modele conceitos de negócio e como eles se relacionam.", "ontology-studio-edit-coming-soon": "A criação visual de relações chegará em breve. Por enquanto, explore o grafo da ontologia ou execute consultas SPARQL.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index fb11f44830b9..20af234bce5f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -37,6 +37,7 @@ "action": "Ação", "action-plural": "Ações", "action-required": "Ação necessária", + "activate-release": "Ativar versão", "active": "Ativo", "active-persona": "Persona ativa", "active-recently": "Activo recentemente", @@ -434,6 +435,7 @@ "caption-expected-value": "esperado {{value}}", "caption-learned-range": "intervalo aprendido (auto)", "caption-threshold": "limite {{value}}", + "capture-release": "Capturar versão", "card": "Cartão", "cardinality": "Cardinalidade", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "Aplicações desativadas", "discard": "Descartar", "discover": "Descobrir", + "disjoint-properties": "Propriedades disjuntas", "dismiss": "Ignorar", "display-name": "Nome de Exibição", "display-name-lowercase": "nome de exibição", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} vezes", "n-times_few": "{{count}} vezes", "n-times_one": "{{count}} vez", + "n-triples-nt": "N-Triples (.nt)", "name": "Nome", "name-is-required": "O nome é obrigatório", "name-lowercase": "nome", "name-this-ingestion": "Nomear esta ingestão", "name-this-service": "Name this service", + "namespace": "Espaço de nomes", "narrow-match": "Correspondência estreita", "narrower": "Mais específico", "native": "Nativo", @@ -2342,8 +2347,10 @@ "ontology-explorer": "Explorador de Ontologia", "ontology-health": "Integridade da ontologia", "ontology-inferred": "Ontologia · inferida", + "ontology-iri": "IRI da ontologia", "ontology-library": "Biblioteca de ontologias", "ontology-relation-plural": "Relações de Ontology", + "ontology-release": "Versão da ontologia", "ontology-studio": "Estúdio de Ontologia", "open": "Abrir", "open-entity": "Abrir {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "Propagado", "properties": "Propriedades", "property": "Propriedade", + "property-chain": "Cadeia de propriedades", + "property-domain": "Classes do domínio", + "property-iri": "IRI da propriedade", "property-plural": "Properties", "propose-term": "Propor termo", "proposed-by": "Proposto por", @@ -2773,6 +2783,7 @@ "reposition": "Reposicionar", "repository-url": "URL do Repositório", "request": "Pedido", + "request-approval": "Pedir aprovação", "request-created-by": "Pedido criado por", "request-data-access": "Solicitar acesso a dados", "request-method": "Método de Pedido", @@ -2957,6 +2968,7 @@ "schema-text": "Texto do esquema", "schema-type": "Tipo de esquema", "schema-validation": "Validação do Esquema", + "scheme-iri": "IRI do esquema de conceitos", "scim-token": "Token SCIM", "scope": "Âmbito", "scope-and-behaviour": "Âmbito e comportamento", @@ -3277,6 +3289,7 @@ "submit-review": "Enviar revisão", "submit-review-count": "Enviar revisão ({{count}})", "submitted-by": "Submetido por", + "submitted-definition": "Definição submetida", "submitted-on": "Submetido em", "submitting": "A submeter", "subscription": "Assinatura", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "Adicione pelo menos dois conceitos e um tipo de relação antes de pedir propostas.", "ontology-ai-select-glossary": "Selecione um glossário para delimitar as propostas de IA e a autorização.", "ontology-ai-standards-placeholder": "Introduza normas separadas por vírgulas, por exemplo FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "A consistência lógica desta versão não foi verificada.", "ontology-edit-held-by": "{{holder}} está a editar este glossário noutra sessão. Feche essa sessão ou aguarde um minuto até expirar e tente novamente.", "ontology-empty-description": "Uma ontologia capta as relações entre os seus termos — o que pertence a quê, o que deriva de quê — para que o seu vocabulário se torne um modelo ligado do seu negócio, não apenas uma lista alfabética.", "ontology-empty-title": "Transforme o seu glossário num mapa de como os conceitos se ligam", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — classes e propriedades de objeto", "ontology-format-skos-description": "Esquema de conceitos com etiquetas e mapeamentos", "ontology-graph-feature-description": "Cada ligação desenha-se aqui: um mapa vivo de como os seus conceitos se encaixam.", + "ontology-graph-limit": "O grafo apresenta até {{count}} conceitos. Utilize o explorador de modelos para ontologias maiores.", "ontology-import-issues-count": "{{count}} item(ns) foram ignorados ou precisam de revisão", "ontology-import-summary": "Encontrado(s) {{terms}} termo(s), {{relations}} relação(ões), {{mappings}} mapeamento(s) de conceitos e {{properties}} propriedade(s) personalizada(s).", "ontology-imported-successfully": "Ontologia importada com sucesso", + "ontology-iri-per-line": "Um IRI por linha; a ordem da cadeia de propriedades é preservada.", "ontology-library-description": "Ontologias pré-construídas e alinhadas com normas. Instale tal como estão ou escolha primeiro os módulos.", "ontology-library-load-error": "Não foi possível carregar a biblioteca de ontologias.", "ontology-not-connected-description": "Estes termos ainda não têm relações. Ligue-os para os posicionar na ontologia.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "Irmãos · ambos sob {{term}}", "ontology-relation-subtype": "{{from}} é um {{to}}", "ontology-relationships-feature-description": "Ligue termos com significado: \"Relacionado com\", \"Parte de\" ou os seus próprios tipos como \"calculado a partir de\".", + "ontology-review-unavailable": "Não foi possível carregar a versão submetida. Pode estar indisponível ou poderá não ter permissão para a ler.", "ontology-studio-description": "Modele conceitos de negócio e a forma como se relacionam.", "ontology-studio-edit-coming-soon": "A criação visual de relações chegará em breve. Por agora, explore o grafo da ontologia ou execute consultas SPARQL.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index 8f79ce88cb99..f56d5407b55c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -37,6 +37,7 @@ "action": "Действие", "action-plural": "Действия", "action-required": "Требуется действие", + "activate-release": "Активировать выпуск", "active": "Активный", "active-persona": "Активная персона", "active-recently": "Активен недавно", @@ -434,6 +435,7 @@ "caption-expected-value": "ожидается {{value}}", "caption-learned-range": "изученный диапазон (авто)", "caption-threshold": "порог {{value}}", + "capture-release": "Создать снимок выпуска", "card": "Карточка", "cardinality": "Кардинальность", "cascade": "Каскадно", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "Отключённые приложения", "discard": "Отклонить", "discover": "Обнаружить", + "disjoint-properties": "Непересекающиеся свойства", "dismiss": "Отклонить", "display-name": "Отображаемое имя", "display-name-lowercase": "отображаемое имя", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} раз", "n-times_few": "{{count}} раза", "n-times_one": "{{count}} раз", + "n-triples-nt": "N-Triples (.nt)", "name": "Наименование", "name-is-required": "Имя обязательно", "name-lowercase": "наименование", "name-this-ingestion": "Назовите это поглощение", "name-this-service": "Назовите сервис", + "namespace": "Пространство имён", "narrow-match": "Узкое соответствие", "narrower": "Уже", "native": "Нативный", @@ -2342,8 +2347,10 @@ "ontology-explorer": "Обозреватель онтологии", "ontology-health": "Состояние онтологии", "ontology-inferred": "Онтология · выведено", + "ontology-iri": "IRI онтологии", "ontology-library": "Библиотека онтологий", "ontology-relation-plural": "Связи ontology", + "ontology-release": "Выпуск онтологии", "ontology-studio": "Студия онтологии", "open": "Открыто", "open-entity": "Открыть {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "Распространяется", "properties": "Свойства", "property": "Свойство", + "property-chain": "Цепочка свойств", + "property-domain": "Классы области определения", + "property-iri": "IRI свойства", "property-plural": "Properties", "propose-term": "Предложить термин", "proposed-by": "Предложено", @@ -2773,6 +2783,7 @@ "reposition": "Изменить положение", "repository-url": "URL Репозитория", "request": "Запрос", + "request-approval": "Запросить одобрение", "request-created-by": "Запрос создан", "request-data-access": "Запросить доступ к данным", "request-method": "Метод запроса", @@ -2957,6 +2968,7 @@ "schema-text": "Текст схемы", "schema-type": "Тип схемы", "schema-validation": "Проверка схемы", + "scheme-iri": "IRI схемы понятий", "scim-token": "Токен SCIM", "scope": "Область", "scope-and-behaviour": "Область видимости и поведение", @@ -3277,6 +3289,7 @@ "submit-review": "Отправить решение", "submit-review-count": "Отправить решение ({{count}})", "submitted-by": "Отправлено", + "submitted-definition": "Отправленное определение", "submitted-on": "Дата отправки", "submitting": "Отправка", "subscription": "Подписка", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "Добавьте хотя бы два понятия и один тип связи перед запросом предложений.", "ontology-ai-select-glossary": "Выберите один глоссарий, чтобы ограничить область предложений ИИ и авторизации.", "ontology-ai-standards-placeholder": "Введите стандарты через запятую, например FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "Логическая согласованность этого выпуска не проверена.", "ontology-edit-held-by": "{{holder}} редактирует этот глоссарий в другом сеансе. Закройте тот сеанс или подождите минуту, пока он истечёт, и повторите попытку.", "ontology-empty-description": "Онтология фиксирует связи между вашими терминами — что к чему относится, что из чего происходит — так что ваш словарь становится связанной моделью вашего бизнеса, а не просто алфавитным списком.", "ontology-empty-title": "Превратите свой глоссарий в карту связей между понятиями", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — классы и свойства объектов", "ontology-format-skos-description": "Схема понятий с метками и сопоставлениями", "ontology-graph-feature-description": "Каждая связь отображается здесь — живая карта того, как ваши понятия сочетаются друг с другом.", + "ontology-graph-limit": "Граф отображает до {{count}} концептов. Для более крупных онтологий используйте браузер модели.", "ontology-import-issues-count": "{{count}} элемент(ов) пропущено или требует проверки", "ontology-import-summary": "Найдено: {{terms}} термин(ов), {{relations}} связь(ей), {{mappings}} сопоставление(й) понятий и {{properties}} пользовательское(их) свойство(в).", "ontology-imported-successfully": "Онтология успешно импортирована", + "ontology-iri-per-line": "Один IRI в строке; порядок цепочки свойств сохраняется.", "ontology-library-description": "Готовые онтологии, соответствующие стандартам. Установите как есть или сначала выберите модули.", "ontology-library-load-error": "Не удалось загрузить библиотеку онтологий.", "ontology-not-connected-description": "У этих терминов пока нет связей. Свяжите их, чтобы разместить в онтологии.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "Одноуровневые · оба под {{term}}", "ontology-relation-subtype": "{{from}} — это {{to}}", "ontology-relationships-feature-description": "Связывайте термины по смыслу: «Связано с», «Часть чего-то» или собственными типами, например «вычисляется из».", + "ontology-review-unavailable": "Не удалось загрузить отправленную версию. Возможно, она недоступна или у вас нет разрешения на её чтение.", "ontology-studio-description": "Моделируйте бизнес-понятия и связи между ними.", "ontology-studio-edit-coming-soon": "Визуальное редактирование связей скоро появится. Пока изучайте граф онтологии или выполняйте SPARQL-запросы.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json index ca5b887dca2e..8b98ebdc9803 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json @@ -37,6 +37,7 @@ "action": "Åtgärd", "action-plural": "Åtgärder", "action-required": "Åtgärd krävs", + "activate-release": "Aktivera version", "active": "Aktiv", "active-persona": "Aktiv persona", "active-recently": "Aktiv nyligen", @@ -434,6 +435,7 @@ "caption-expected-value": "förväntat {{value}}", "caption-learned-range": "inlärt intervall (auto)", "caption-threshold": "tröskel {{value}}", + "capture-release": "Skapa versionskopia", "card": "Kort", "cardinality": "Kardinalitet", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "Inaktiverade applikationer", "discard": "Förkasta", "discover": "Upptäck", + "disjoint-properties": "Disjunkta egenskaper", "dismiss": "Avfärda", "display-name": "Visningsnamn", "display-name-lowercase": "visningsnamn", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} gånger", "n-times_few": "{{count}} gånger", "n-times_one": "{{count}} gång", + "n-triples-nt": "N-Triples (.nt)", "name": "Namn", "name-is-required": "Name is required", "name-lowercase": "namn", "name-this-ingestion": "Namnge denna inmatning", "name-this-service": "Namnge denna tjänst", + "namespace": "Namnrymd", "narrow-match": "Smal matchning", "narrower": "Smalare", "native": "Native", @@ -2342,8 +2347,10 @@ "ontology-explorer": "Ontologiutforskaren", "ontology-health": "Ontologins status", "ontology-inferred": "Ontologi · härledd", + "ontology-iri": "Ontologi-IRI", "ontology-library": "Ontologibibliotek", "ontology-relation-plural": "Ontologirelationer", + "ontology-release": "Ontologiversion", "ontology-studio": "Ontologistudio", "open": "Öppna", "open-entity": "Öppna {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "Propagerad", "properties": "Egenskaper", "property": "Egenskap", + "property-chain": "Egenskapskedja", + "property-domain": "Domänklasser", + "property-iri": "Egenskaps-IRI", "property-plural": "Properties", "propose-term": "Föreslå term", "proposed-by": "Föreslagen av", @@ -2773,6 +2783,7 @@ "reposition": "Omplacera", "repository-url": "Repository-URL", "request": "Begäran", + "request-approval": "Begär godkännande", "request-created-by": "Request created by", "request-data-access": "Begär dataåtkomst", "request-method": "Begäransmetod", @@ -2957,6 +2968,7 @@ "schema-text": "Schematext", "schema-type": "Schematyp", "schema-validation": "Schemavalidering", + "scheme-iri": "Begreppsschemats IRI", "scim-token": "SCIM-token", "scope": "Omfattning", "scope-and-behaviour": "Omfång och beteende", @@ -3277,6 +3289,7 @@ "submit-review": "Skicka granskning", "submit-review-count": "Skicka granskning ({{count}})", "submitted-by": "Skickat av", + "submitted-definition": "Inskickad definition", "submitted-on": "Skickat den", "submitting": "Skickar", "subscription": "Prenumeration", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "Lägg till minst två begrepp och en relationstyp innan du begär förslag.", "ontology-ai-select-glossary": "Välj en ordlista för att avgränsa AI-förslag och behörighet.", "ontology-ai-standards-placeholder": "Ange kommaseparerade standarder, till exempel FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "Den logiska konsistensen för denna version har inte kontrollerats.", "ontology-edit-held-by": "{{holder}} redigerar den här ordlistan i en annan session. Stäng den sessionen eller vänta en minut tills den löper ut och försök sedan igen.", "ontology-empty-description": "En ontologi fångar relationerna mellan dina termer – vad som hör till vad, vad som härleds från vad – så att ditt ordförråd blir en sammankopplad modell av din verksamhet, inte bara en alfabetisk lista.", "ontology-empty-title": "Förvandla din ordlista till en karta över hur begrepp hänger ihop", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — klasser och objektegenskaper", "ontology-format-skos-description": "Konceptschema med etiketter och mappningar", "ontology-graph-feature-description": "Varje länk ritas här – en levande karta över hur dina begrepp passar ihop.", + "ontology-graph-limit": "Grafen visar upp till {{count}} begrepp. Använd modellutforskaren för större ontologier.", "ontology-import-issues-count": "{{count}} objekt hoppades över eller behöver granskas", "ontology-import-summary": "Hittade {{terms}} term(er), {{relations}} relation(er), {{mappings}} konceptmappning(ar) och {{properties}} anpassad(e) egenskap(er).", "ontology-imported-successfully": "Ontologin importerades", + "ontology-iri-per-line": "En IRI per rad; egenskapskedjans ordning bevaras.", "ontology-library-description": "Färdiga, standardanpassade ontologier. Installera som de är, eller välj moduler först.", "ontology-library-load-error": "Ontologibiblioteket kunde inte läsas in.", "ontology-not-connected-description": "Dessa termer har inga relationer än. Anslut dem för att placera dem i ontologin.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "Syskon · båda under {{term}}", "ontology-relation-subtype": "{{from}} är en {{to}}", "ontology-relationships-feature-description": "Länka termer med betydelse: \"Relaterad till\", \"Del av\" eller egna typer som \"beräknad från\".", + "ontology-review-unavailable": "Den inskickade versionen kunde inte läsas in. Den kan vara otillgänglig eller så saknar du behörighet att läsa den.", "ontology-studio-description": "Modellera affärsbegrepp och hur de hänger ihop.", "ontology-studio-edit-coming-soon": "Visuell redigering av relationer kommer snart. Utforska tills vidare ontologigrafen eller kör SPARQL-frågor.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index dbbed38dbdf5..d2b37d62ca0c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -37,6 +37,7 @@ "action": "การดำเนินการ", "action-plural": "การดำเนินการ", "action-required": "ต้องดำเนินการ", + "activate-release": "เปิดใช้งานรุ่น", "active": "ใช้งานอยู่", "active-persona": "เปอร์โซนาที่ใช้งานอยู่", "active-recently": "ใช้งานล่าสุด", @@ -434,6 +435,7 @@ "caption-expected-value": "ค่าที่คาดหวัง {{value}}", "caption-learned-range": "ช่วงที่เรียนรู้ (อัตโนมัติ)", "caption-threshold": "เกณฑ์ {{value}}", + "capture-release": "บันทึกภาพรวมของรุ่น", "card": "การ์ด", "cardinality": "จำนวนสมาชิก", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "แอปพลิเคชันที่ปิดใช้งาน", "discard": "ทิ้ง", "discover": "ค้นพบ", + "disjoint-properties": "คุณสมบัติที่ไม่ทับซ้อนกัน", "dismiss": "ละทิ้ง", "display-name": "ชื่อที่แสดง", "display-name-lowercase": "ชื่อแสดง", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} ครั้ง", "n-times_few": "{{count}} ครั้ง", "n-times_one": "{{count}} ครั้ง", + "n-triples-nt": "N-Triples (.nt)", "name": "ชื่อ", "name-is-required": "ต้องระบุชื่อ", "name-lowercase": "ชื่อ", "name-this-ingestion": "ตั้งชื่อการนำเข้านี้", "name-this-service": "Name this service", + "namespace": "เนมสเปซ", "narrow-match": "การจับคู่แบบแคบ", "narrower": "แคบกว่า", "native": "เนทีฟ", @@ -2342,8 +2347,10 @@ "ontology-explorer": "ตัวสำรวจออนโทโลยี", "ontology-health": "สถานะออนโทโลจี", "ontology-inferred": "ออนโทโลยี · อนุมาน", + "ontology-iri": "IRI ของออนโทโลยี", "ontology-library": "คลังออนโทโลยี", "ontology-relation-plural": "ความสัมพันธ์ของ Ontology", + "ontology-release": "รุ่นออนโทโลยี", "ontology-studio": "สตูดิโอออนโทโลยี", "open": "เปิด", "open-entity": "เปิด {{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "กระจาย", "properties": "คุณสมบัติ", "property": "คุณสมบัติ", + "property-chain": "สายโซ่คุณสมบัติ", + "property-domain": "คลาสโดเมน", + "property-iri": "IRI ของคุณสมบัติ", "property-plural": "Properties", "propose-term": "เสนอคำศัพท์", "proposed-by": "เสนอโดย", @@ -2773,6 +2783,7 @@ "reposition": "ปรับตำแหน่งใหม่", "repository-url": "URL ที่เก็บข้อมูล", "request": "คำขอ", + "request-approval": "ขออนุมัติ", "request-created-by": "คำขอสร้างโดย", "request-data-access": "ขอการเข้าถึงข้อมูล", "request-method": "วิธีการของคำขอ", @@ -2957,6 +2968,7 @@ "schema-text": "ข้อความสคีมา", "schema-type": "ประเภทสคีมา", "schema-validation": "การตรวจสอบสคีมา", + "scheme-iri": "IRI ของแบบแผนแนวคิด", "scim-token": "โทเค็น SCIM", "scope": "ขอบเขต", "scope-and-behaviour": "ขอบเขตและพฤติกรรม", @@ -3277,6 +3289,7 @@ "submit-review": "ส่งผลการตรวจสอบ", "submit-review-count": "ส่งผลการตรวจสอบ ({{count}})", "submitted-by": "ส่งโดย", + "submitted-definition": "คำจำกัดความที่ส่ง", "submitted-on": "ส่งเมื่อ", "submitting": "กำลังส่ง", "subscription": "การสมัครสมาชิก", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "เพิ่มแนวคิดอย่างน้อยสองรายการและชนิดความสัมพันธ์หนึ่งชนิดก่อนขอข้อเสนอ", "ontology-ai-select-glossary": "เลือกอภิธานศัพท์หนึ่งรายการเพื่อกำหนดขอบเขตข้อเสนอ AI และการอนุญาต", "ontology-ai-standards-placeholder": "ป้อนมาตรฐานโดยคั่นด้วยจุลภาค เช่น FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "ยังไม่ได้ตรวจสอบความสอดคล้องเชิงตรรกะของรุ่นนี้", "ontology-edit-held-by": "{{holder}} กำลังแก้ไขอภิธานศัพท์นี้ในเซสชันอื่น ปิดเซสชันนั้นหรือรอหนึ่งนาทีให้หมดอายุ แล้วลองอีกครั้ง", "ontology-empty-description": "ออนโทโลยีจับความสัมพันธ์ระหว่างคำศัพท์ของคุณ — อะไรเป็นของอะไร อะไรมาจากอะไร — เพื่อให้คำศัพท์ของคุณกลายเป็นแบบจำลองที่เชื่อมโยงกันของธุรกิจ ไม่ใช่แค่รายการเรียงตามตัวอักษร", "ontology-empty-title": "เปลี่ยนอภิธานศัพท์ของคุณให้เป็นแผนที่ว่าแนวคิดเชื่อมโยงกันอย่างไร", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — คลาสและคุณสมบัติของอ็อบเจกต์", "ontology-format-skos-description": "โครงร่างแนวคิดพร้อมป้ายกำกับและการแมป", "ontology-graph-feature-description": "ทุกการเชื่อมโยงจะถูกวาดขึ้นที่นี่ — แผนที่ที่มีชีวิตซึ่งแสดงว่าแนวคิดของคุณประกอบเข้าด้วยกันอย่างไร", + "ontology-graph-limit": "กราฟแสดงแนวคิดได้สูงสุด {{count}} รายการ ใช้ตัวสำรวจแบบจำลองสำหรับออนโทโลยีที่ใหญ่กว่า", "ontology-import-issues-count": "{{count}} รายการถูกข้ามหรือต้องตรวจสอบ", "ontology-import-summary": "พบ {{terms}} คำศัพท์, {{relations}} ความสัมพันธ์, {{mappings}} การแม็ปแนวคิด และ {{properties}} คุณสมบัติที่กำหนดเอง", "ontology-imported-successfully": "นำเข้าออนโทโลยีสำเร็จแล้ว", + "ontology-iri-per-line": "หนึ่ง IRI ต่อบรรทัด โดยคงลำดับของสายโซ่คุณสมบัติไว้", "ontology-library-description": "ออนโทโลยีสำเร็จรูปที่สอดคล้องกับมาตรฐาน ติดตั้งได้ทันที หรือเลือกโมดูลก่อน", "ontology-library-load-error": "ไม่สามารถโหลดคลังออนโทโลยีได้", "ontology-not-connected-description": "คำเหล่านี้ยังไม่มีความสัมพันธ์ เชื่อมต่อเพื่อจัดวางในออนโทโลยี", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "พี่น้อง · ทั้งคู่อยู่ภายใต้ {{term}}", "ontology-relation-subtype": "{{from}} เป็น {{to}}", "ontology-relationships-feature-description": "เชื่อมโยงคำศัพท์ด้วยความหมาย: \"เกี่ยวข้องกับ\", \"เป็นส่วนหนึ่งของ\" หรือประเภทของคุณเองเช่น \"คำนวณจาก\"", + "ontology-review-unavailable": "ไม่สามารถโหลดเวอร์ชันที่ส่งได้ เวอร์ชันนี้อาจไม่พร้อมใช้งานหรือคุณอาจไม่มีสิทธิ์อ่าน", "ontology-studio-description": "สร้างแบบจำลองแนวคิดทางธุรกิจและความสัมพันธ์ระหว่างกัน", "ontology-studio-edit-coming-soon": "การสร้างความสัมพันธ์แบบภาพกำลังจะมาเร็ว ๆ นี้ ในระหว่างนี้ สำรวจกราฟออนโทโลยีหรือรันคำสั่ง SPARQL", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json index 3cb9ee79f3e8..fc3ae738ab3a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json @@ -37,6 +37,7 @@ "action": "Eylem", "action-plural": "Eylemler", "action-required": "Eylem Gerekli", + "activate-release": "Sürümü etkinleştir", "active": "Aktif", "active-persona": "Etkin persona", "active-recently": "Son zamanlarda aktif", @@ -434,6 +435,7 @@ "caption-expected-value": "beklenen {{value}}", "caption-learned-range": "öğrenilen aralık (otomatik)", "caption-threshold": "eşik {{value}}", + "capture-release": "Sürüm anlık görüntüsünü oluştur", "card": "Kart", "cardinality": "Kardinalite", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "Devre dışı uygulamalar", "discard": "At", "discover": "Keşfet", + "disjoint-properties": "Ayrık özellikler", "dismiss": "Reddet", "display-name": "Görünen Ad", "display-name-lowercase": "görünen ad", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} kez", "n-times_few": "{{count}} kez", "n-times_one": "{{count}} kez", + "n-triples-nt": "N-Triples (.nt)", "name": "Ad", "name-is-required": "Ad zorunludur", "name-lowercase": "ad", "name-this-ingestion": "Bu alımı adlandırın", "name-this-service": "Name this service", + "namespace": "Ad alanı", "narrow-match": "Dar eşleşme", "narrower": "Daha Dar", "native": "Yerel", @@ -2342,8 +2347,10 @@ "ontology-explorer": "Ontoloji Gezgini", "ontology-health": "Ontoloji durumu", "ontology-inferred": "Ontoloji · çıkarımsal", + "ontology-iri": "Ontoloji IRI’si", "ontology-library": "Ontoloji Kitaplığı", "ontology-relation-plural": "Ontology ilişkileri", + "ontology-release": "Ontoloji sürümü", "ontology-studio": "Ontoloji Stüdyosu", "open": "Açık", "open-entity": "{{entity}} Aç", @@ -2596,6 +2603,9 @@ "propagated": "Yayılır", "properties": "Özellikler", "property": "Özellik", + "property-chain": "Özellik zinciri", + "property-domain": "Tanım alanı sınıfları", + "property-iri": "Özellik IRI’si", "property-plural": "Properties", "propose-term": "Terim öner", "proposed-by": "Öneren", @@ -2773,6 +2783,7 @@ "reposition": "Yeniden konumlandır", "repository-url": "Depo URL'si", "request": "İstek", + "request-approval": "Onay iste", "request-created-by": "Request created by", "request-data-access": "Veri Erişimi İste", "request-method": "İstek Metodu", @@ -2957,6 +2968,7 @@ "schema-text": "Şema Metni", "schema-type": "Şema Türü", "schema-validation": "Şema doğrulama", + "scheme-iri": "Kavram şeması IRI’si", "scim-token": "SCIM Token", "scope": "Kapsam", "scope-and-behaviour": "Kapsam ve davranış", @@ -3277,6 +3289,7 @@ "submit-review": "İncelemeyi gönder", "submit-review-count": "İncelemeyi gönder ({{count}})", "submitted-by": "Gönderen", + "submitted-definition": "Gönderilen tanım", "submitted-on": "Gönderilme Tarihi", "submitting": "Gönderiliyor", "subscription": "Abonelik", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "Öneri istemeden önce en az iki kavram ve bir ilişki türü ekleyin.", "ontology-ai-select-glossary": "Yapay zekâ önerilerini ve yetkilendirmeyi kapsamlandırmak için bir sözlük seçin.", "ontology-ai-standards-placeholder": "Virgülle ayrılmış standartları girin; örneğin FIBO, FHIR, schema.org", + "ontology-consistency-not-run": "Bu sürümün mantıksal tutarlılığı kontrol edilmedi.", "ontology-edit-held-by": "{{holder}} bu sözlüğü başka bir oturumda düzenliyor. O oturumu kapatın veya süresinin dolması için bir dakika bekleyip yeniden deneyin.", "ontology-empty-description": "Bir ontoloji, terimleriniz arasındaki ilişkileri (neyin neye ait olduğunu, neyin neyden türediğini) yakalar; böylece kelime dağarcığınız yalnızca alfabetik bir liste değil, işinizin bağlantılı bir modeli hâline gelir.", "ontology-empty-title": "Sözlüğünüzü, kavramların nasıl bağlandığını gösteren bir haritaya dönüştürün", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — sınıflar ve nesne özellikleri", "ontology-format-skos-description": "Etiketler ve eşlemelerle kavram şeması", "ontology-graph-feature-description": "Her bağlantı burada çizilir; kavramlarınızın nasıl bir araya geldiğini gösteren canlı bir harita.", + "ontology-graph-limit": "Grafik en fazla {{count}} kavram gösterir. Daha büyük ontolojiler için model gezginini kullanın.", "ontology-import-issues-count": "{{count}} öğe atlandı veya gözden geçirilmesi gerekiyor", "ontology-import-summary": "{{terms}} terim, {{relations}} ilişki, {{mappings}} kavram eşlemesi ve {{properties}} özel özellik bulundu.", "ontology-imported-successfully": "Ontoloji başarıyla içe aktarıldı", + "ontology-iri-per-line": "Her satıra bir IRI girin; özellik zincirinin sırası korunur.", "ontology-library-description": "Hazır, standartlarla uyumlu ontolojiler. Olduğu gibi yükleyin veya önce modülleri seçin.", "ontology-library-load-error": "Ontoloji kitaplığı yüklenemedi.", "ontology-not-connected-description": "Bu terimlerin henüz ilişkisi yok. Ontolojiye yerleştirmek için bunları bağlayın.", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "Kardeşler · ikisi de {{term}} altında", "ontology-relation-subtype": "{{from}}, bir {{to}}", "ontology-relationships-feature-description": "Terimleri anlamla bağlayın: \"İlişkili\", \"Parçası\" veya \"şundan hesaplanır\" gibi kendi türleriniz.", + "ontology-review-unavailable": "Gönderilen sürüm yüklenemedi. Kullanılamıyor olabilir veya okuma izniniz olmayabilir.", "ontology-studio-description": "İş kavramlarını ve aralarındaki ilişkileri modelleyin.", "ontology-studio-edit-coming-soon": "Görsel ilişki düzenleme yakında geliyor. Şimdilik ontoloji grafiğini keşfedin veya SPARQL sorguları çalıştırın.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index 828898d082c4..eec161a04387 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -37,6 +37,7 @@ "action": "操作", "action-plural": "操作", "action-required": "需要操作", + "activate-release": "激活发布版本", "active": "活跃", "active-persona": "当前角色", "active-recently": "最近活跃", @@ -434,6 +435,7 @@ "caption-expected-value": "预期 {{value}}", "caption-learned-range": "学习范围(自动)", "caption-threshold": "阈值 {{value}}", + "capture-release": "创建发布快照", "card": "卡片", "cardinality": "基数", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "已禁用的应用", "discard": "丢弃", "discover": "发现", + "disjoint-properties": "互斥属性", "dismiss": "忽略", "display-name": "显示名称", "display-name-lowercase": "显示名称", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} 次", "n-times_few": "{{count}} 次", "n-times_one": "{{count}} 次", + "n-triples-nt": "N-Triples (.nt)", "name": "名称", "name-is-required": "名称为必填项", "name-lowercase": "名称", "name-this-ingestion": "为此摄取命名", "name-this-service": "Name this service", + "namespace": "命名空间", "narrow-match": "狭义匹配", "narrower": "下位", "native": "原生", @@ -2342,8 +2347,10 @@ "ontology-explorer": "本体浏览器", "ontology-health": "本体健康状态", "ontology-inferred": "本体 · 推断", + "ontology-iri": "本体 IRI", "ontology-library": "本体资源库", "ontology-relation-plural": "Ontology 关系", + "ontology-release": "本体发布版本", "ontology-studio": "本体工作室", "open": "打开", "open-entity": "打开{{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "传播", "properties": "属性", "property": "属性", + "property-chain": "属性链", + "property-domain": "定义域类", + "property-iri": "属性 IRI", "property-plural": "Properties", "propose-term": "提议术语", "proposed-by": "提议者", @@ -2773,6 +2783,7 @@ "reposition": "重新定位", "repository-url": "仓库URL", "request": "请求", + "request-approval": "请求审批", "request-created-by": "请求创建者", "request-data-access": "请求数据访问", "request-method": "请求方式", @@ -2957,6 +2968,7 @@ "schema-text": "数据模式文本", "schema-type": "架构类型", "schema-validation": "架构验证", + "scheme-iri": "概念体系 IRI", "scim-token": "SCIM 令牌", "scope": "范围", "scope-and-behaviour": "范围与行为", @@ -3277,6 +3289,7 @@ "submit-review": "提交审核", "submit-review-count": "提交审核 ({{count}})", "submitted-by": "提交者", + "submitted-definition": "已提交的定义", "submitted-on": "提交时间", "submitting": "提交中", "subscription": "订阅", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "请求建议前,请添加至少两个概念和一种关系类型。", "ontology-ai-select-glossary": "选择一个术语表,以限定 AI 建议和授权的范围。", "ontology-ai-standards-placeholder": "输入以逗号分隔的标准,例如 FIBO、FHIR、schema.org", + "ontology-consistency-not-run": "尚未检查此发布版本的逻辑一致性。", "ontology-edit-held-by": "{{holder}} 正在另一个会话中编辑此术语表。请关闭该会话或等待一分钟使其过期,然后重试。", "ontology-empty-description": "本体捕获术语之间的关系——什么属于什么、什么派生自什么——使您的词汇成为业务的关联模型,而不仅仅是按字母排序的列表。", "ontology-empty-title": "将您的术语表转化为概念如何关联的地图", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — 类和对象属性", "ontology-format-skos-description": "带标签和映射的概念方案", "ontology-graph-feature-description": "每条连接都会在此绘制——一张展示概念如何组合在一起的动态地图。", + "ontology-graph-limit": "图谱最多显示 {{count}} 个概念。对于更大的本体,请使用模型浏览器。", "ontology-import-issues-count": "{{count}} 个项目被跳过或需要审查", "ontology-import-summary": "找到 {{terms}} 个术语、{{relations}} 个关系、{{mappings}} 个概念映射和 {{properties}} 个自定义属性。", "ontology-imported-successfully": "本体导入成功", + "ontology-iri-per-line": "每行一个 IRI;属性链的顺序将被保留。", "ontology-library-description": "预构建、符合标准的本体。可直接安装,或先选择模块。", "ontology-library-load-error": "无法加载本体资源库。", "ontology-not-connected-description": "这些术语还没有任何关系。连接它们以将其放入本体中。", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "同级 · 均属于 {{term}}", "ontology-relation-subtype": "{{from}} 是一种 {{to}}", "ontology-relationships-feature-description": "用含义关联术语:“相关于”“属于”,或您自定义的类型,例如“计算自”。", + "ontology-review-unavailable": "无法加载已提交的版本。该版本可能不可用,或您没有读取权限。", "ontology-studio-description": "为业务概念及其相互关系建模。", "ontology-studio-edit-coming-soon": "关系可视化编辑即将推出。目前,您可以浏览本体图谱或运行 SPARQL 查询。", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json index dca59393dc4f..96947aa18db7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json @@ -37,6 +37,7 @@ "action": "操作", "action-plural": "操作", "action-required": "需要操作", + "activate-release": "啟用發佈版本", "active": "啟用中", "active-persona": "目前角色", "active-recently": "最近活躍", @@ -434,6 +435,7 @@ "caption-expected-value": "預期 {{value}}", "caption-learned-range": "學習範圍(自動)", "caption-threshold": "閾值 {{value}}", + "capture-release": "建立發佈快照", "card": "卡片", "cardinality": "基數", "cascade": "Cascade", @@ -1008,6 +1010,7 @@ "disabled-application-plural": "已停用的應用程式", "discard": "放棄", "discover": "探索", + "disjoint-properties": "互斥屬性", "dismiss": "忽略", "display-name": "顯示名稱", "display-name-lowercase": "顯示名稱", @@ -2148,11 +2151,13 @@ "n-times": "{{count}} 次", "n-times_few": "{{count}} 次", "n-times_one": "{{count}} 次", + "n-triples-nt": "N-Triples (.nt)", "name": "名稱", "name-is-required": "名稱為必填項", "name-lowercase": "名稱", "name-this-ingestion": "為此攝取命名", "name-this-service": "Name this service", + "namespace": "命名空間", "narrow-match": "狹義比對", "narrower": "下位", "native": "原生", @@ -2342,8 +2347,10 @@ "ontology-explorer": "本體瀏覽器", "ontology-health": "本體健康狀態", "ontology-inferred": "本體 · 推斷", + "ontology-iri": "本體 IRI", "ontology-library": "本體資源庫", "ontology-relation-plural": "Ontology 關係", + "ontology-release": "本體發佈版本", "ontology-studio": "本體工作室", "open": "開啟", "open-entity": "開啟{{entity}}", @@ -2596,6 +2603,9 @@ "propagated": "已傳播", "properties": "屬性", "property": "屬性", + "property-chain": "屬性鏈", + "property-domain": "定義域類別", + "property-iri": "屬性 IRI", "property-plural": "Properties", "propose-term": "提議術語", "proposed-by": "提議者", @@ -2773,6 +2783,7 @@ "reposition": "重新定位", "repository-url": "仓库URL", "request": "請求", + "request-approval": "請求核准", "request-created-by": "Request created by", "request-data-access": "請求資料存取", "request-method": "請求方法", @@ -2957,6 +2968,7 @@ "schema-text": "結構文字", "schema-type": "結構類型", "schema-validation": "結構驗證", + "scheme-iri": "概念體系 IRI", "scim-token": "SCIM Token", "scope": "範圍", "scope-and-behaviour": "範圍與行為", @@ -3277,6 +3289,7 @@ "submit-review": "提交審核", "submit-review-count": "提交審核 ({{count}})", "submitted-by": "提交者", + "submitted-definition": "已提交的定義", "submitted-on": "提交時間", "submitting": "提交中", "subscription": "訂閱", @@ -5395,6 +5408,7 @@ "ontology-ai-relationship-scope-empty": "要求建議前,請新增至少兩個概念和一種關係類型。", "ontology-ai-select-glossary": "選取一個詞彙表,以界定 AI 建議和授權的範圍。", "ontology-ai-standards-placeholder": "輸入以逗號分隔的標準,例如 FIBO、FHIR、schema.org", + "ontology-consistency-not-run": "尚未檢查此發佈版本的邏輯一致性。", "ontology-edit-held-by": "{{holder}} 正在另一個工作階段中編輯此詞彙表。請關閉該工作階段或等待一分鐘使其過期,然後再試一次。", "ontology-empty-description": "本體論擷取詞彙之間的關係——什麼屬於什麼、什麼衍生自什麼——使您的詞彙成為業務的連結模型,而不僅是按字母排序的清單。", "ontology-empty-title": "將您的詞彙表轉化為概念如何連結的地圖", @@ -5403,9 +5417,11 @@ "ontology-format-owl-description": "RDF/XML — 類別與物件屬性", "ontology-format-skos-description": "帶標籤與對應的概念綱要", "ontology-graph-feature-description": "每條連結都會在此繪製——一張展示概念如何組合的動態地圖。", + "ontology-graph-limit": "圖譜最多顯示 {{count}} 個概念。對於更大的本體,請使用模型瀏覽器。", "ontology-import-issues-count": "{{count}} 個項目被略過或需要審查", "ontology-import-summary": "找到 {{terms}} 個術語、{{relations}} 個關聯、{{mappings}} 個概念對應與 {{properties}} 個自訂屬性。", "ontology-imported-successfully": "本體匯入成功", + "ontology-iri-per-line": "每行一個 IRI;屬性鏈的順序將予以保留。", "ontology-library-description": "預先建置、符合標準的本體。可直接安裝,或先選擇模組。", "ontology-library-load-error": "無法載入本體資源庫。", "ontology-not-connected-description": "這些術語尚未有任何關係。連接它們以將其放入本體中。", @@ -5427,6 +5443,7 @@ "ontology-relation-siblings": "同層級 · 皆屬於 {{term}}", "ontology-relation-subtype": "{{from}} 是一種 {{to}}", "ontology-relationships-feature-description": "用含義連結詞彙:「相關於」「屬於」,或您自訂的類型,例如「計算自」。", + "ontology-review-unavailable": "無法載入已提交的版本。該版本可能無法使用,或您沒有讀取權限。", "ontology-studio-description": "為業務概念及其相互關係建模。", "ontology-studio-edit-coming-soon": "關係視覺化編輯即將推出。目前,您可以瀏覽本體圖譜或執行 SPARQL 查詢。", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.integration.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.integration.test.tsx index 55e7f2482a7e..32252cf5fcfe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.integration.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.integration.test.tsx @@ -18,7 +18,7 @@ import { screen, waitFor, } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, useLocation } from 'react-router-dom'; import { OntologyNode } from '../../components/OntologyExplorer/OntologyExplorer.interface'; import { ProjectionState, RDFStatus } from '../../generated/api/rdf/rdfStatus'; import { Glossary } from '../../generated/entity/data/glossary'; @@ -29,7 +29,7 @@ import { getOntologySummary, } from '../../rest/glossaryAPI'; import { getMetrics } from '../../rest/metricsAPI'; -import { listRelationshipTypes } from '../../rest/ontologyAPI'; +import { getOntology, listRelationshipTypes } from '../../rest/ontologyAPI'; import { checkRdfEnabled, fetchRdfConfig, @@ -83,6 +83,7 @@ const mockGetGlossaryTerms = getGlossaryTerms as jest.MockedFunction< typeof getGlossaryTerms >; const mockGetMetrics = getMetrics as jest.MockedFunction; +const mockGetOntology = getOntology as jest.MockedFunction; const mockListRelationshipTypes = listRelationshipTypes as jest.MockedFunction< typeof listRelationshipTypes >; @@ -165,6 +166,44 @@ describe('Ontology Studio mode-switch integration', () => { }); }); + it.each([403, 404])( + 'returns to the glossary graph when an ontology deep link fails with %s', + async (status) => { + mockGetOntology.mockRejectedValueOnce({ response: { status } }); + mockCheckRdfEnabled.mockResolvedValue(false); + mockFetchRdfConfig.mockResolvedValue({ ...rdfStatus, enabled: false }); + const Location = () => ( + {useLocation().search} + ); + + render( + + + + + ); + + expect(screen.getByText('label.loading')).toBeVisible(); + + await waitFor(() => + expect(screen.getByTestId('graph-nodes')).toBeVisible() + ); + + expect(screen.getByTestId('graph-nodes')).toHaveTextContent('Account'); + expect(screen.getByTestId('location').textContent).toBe( + '?source=context-center' + ); + expect(screen.getByTestId('mode-tab-view')).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.queryByText('label.loading')).not.toBeInTheDocument(); + } + ); + it.each([true, false])( 'keeps loaded terms, relations and search when Query is visited (RDF enabled: %s)', async (rdfEnabled) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.test.tsx index 8596ba349a4d..5148ffe19db0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.test.tsx @@ -11,18 +11,54 @@ * limitations under the License. */ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { ReactNode } from 'react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; import { Glossary } from '../../generated/entity/data/glossary'; +import { Ontology } from '../../generated/entity/data/ontology'; +import { OntologyConceptType } from '../../generated/entity/data/ontologyConcept'; +import { + getOntology, + listOntologies, + listOntologyConcepts, +} from '../../rest/ontologyAPI'; +import { renderWithQueryClient } from '../../test/unit/test-utils'; import { showInfoToast } from '../../utils/ToastUtils'; import OntologyExplorerPage from './OntologyExplorerPage'; let mockSearchParams = new URLSearchParams(); +let mockUseRealRouter = false; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), - useSearchParams: () => [mockSearchParams, jest.fn()], + useSearchParams: () => + mockUseRealRouter + ? jest.requireActual('react-router-dom').useSearchParams() + : [mockSearchParams, jest.fn()], +})); + +jest.mock('../../rest/ontologyAPI', () => ({ + ...jest.requireActual('../../rest/ontologyAPI'), + getOntology: jest.fn(), + listOntologies: jest.fn(), + listOntologyConcepts: jest.fn(), +})); + +jest.mock('../../rest/permissionAPI', () => ({ + ...jest.requireActual('../../rest/permissionAPI'), + getEntityPermissionById: jest.fn().mockResolvedValue({ operations: [] }), +})); + +jest.mock('../../components/OntologyExplorer/OntologyGraphG6', () => ({ + __esModule: true, + default: () =>
    , })); interface ExplorerMockProps { @@ -62,7 +98,7 @@ const mockUseOntologyAiCapability = jest.fn(() => ({ isLoading: false, isRdfEnabled: true, })); -const mockUseOntologyEditLease = jest.fn(() => ({ +const mockUseOntologyEditLease = jest.fn((_options?: unknown) => ({ isOwned: true, lock: undefined, retry: jest.fn(), @@ -93,7 +129,8 @@ jest.mock( jest.mock( '../../components/OntologyExplorer/hooks/useOntologyEditLease', () => ({ - useOntologyEditLease: () => mockUseOntologyEditLease(), + useOntologyEditLease: (options: unknown) => + mockUseOntologyEditLease(options), }) ); @@ -192,6 +229,8 @@ jest.mock( describe('OntologyExplorerPage', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseRealRouter = false; + mockSearchParams = new URLSearchParams(); mockUseAuth.mockReturnValue({ isAdminUser: true, isFirstTimeUser: false, @@ -203,6 +242,113 @@ describe('OntologyExplorerPage', () => { }); }); + const nativeOntology: Ontology = { + id: 'native-sales', + name: 'Native sales', + fullyQualifiedName: 'NativeSales', + iri: 'urn:NativeSales', + namespace: 'urn:NativeSales:', + }; + + const Location = () => ( + {useLocation().search} + ); + + const renderNativeStudio = (search: string) => { + mockUseRealRouter = true; + (getOntology as jest.Mock).mockResolvedValue(nativeOntology); + (listOntologies as jest.Mock).mockResolvedValue({ + data: [nativeOntology], + paging: { total: 1 }, + }); + (listOntologyConcepts as jest.Mock).mockResolvedValue({ + data: ['Party', 'Customer'].map((name) => ({ + id: name, + name, + iri: `urn:${name}`, + fullyQualifiedName: `NativeSales.${name}`, + ontology: { id: nativeOntology.id, type: 'ontology' }, + conceptType: OntologyConceptType.OwlClass, + superClasses: name === 'Customer' ? ['urn:Party'] : [], + })), + paging: { total: 2 }, + }); + + return renderWithQueryClient( + + + + + ); + }; + + it('switches from a glossary draft to a native tree and releases the glossary lease', async () => { + renderNativeStudio('?draft=glossary-draft'); + const glossary: Glossary = { + id: 'glossary-id', + name: 'Business glossary', + description: '', + }; + act(() => + mockOntologyExplorer.mock.calls + .at(-1)?.[0] + .onGlossariesChange?.([glossary]) + ); + fireEvent.click(screen.getByTestId('ontology-glossary-menu-trigger')); + fireEvent.click( + screen.getByRole('menuitemradio', { name: /Business glossary/ }) + ); + fireEvent.click(screen.getByTestId('mode-tab-edit')); + + expect(mockUseOntologyEditLease).toHaveBeenLastCalledWith( + expect.objectContaining({ isActive: true }) + ); + + fireEvent.click( + screen.getByRole('button', { name: 'label.select-entity' }) + ); + fireEvent.click(await screen.findByText(nativeOntology.name)); + await screen.findByTestId('mode-tab-model'); + + expect(screen.getByTestId('mode-tab-graph')).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByTestId('location')).toHaveTextContent( + '?ontology=native-sales' + ); + expect(screen.getByTestId('location')).not.toHaveTextContent('draft='); + expect(mockUseOntologyEditLease).toHaveBeenLastCalledWith( + expect.objectContaining({ isActive: false }) + ); + + fireEvent.click(await screen.findByTestId('submode-tab-tree')); + await waitFor(() => + expect(screen.getByTestId('ontology-tree-term-Customer')).toBeVisible() + ); + + expect(screen.getByTestId('ontology-tree-term-Party')).toBeVisible(); + }); + + it('keeps a native draft deep link in its selected ontology review', async () => { + renderNativeStudio('?ontology=native-sales&draft=native-draft'); + + await waitFor(() => + expect(screen.getByTestId('mode-tab-review')).toHaveAttribute( + 'aria-selected', + 'true' + ) + ); + + expect(screen.getByTestId('location')).toHaveTextContent( + 'draft=native-draft' + ); + expect(screen.getByTestId('ontology-memory-review-panel')).toBeVisible(); + expect(mockUseOntologyEditLease).toHaveBeenLastCalledWith( + expect.objectContaining({ isActive: false }) + ); + }); + it('renders the dedicated Studio shell and graph by default', () => { render(); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.tsx index 3be28904cbb5..5e9820659d13 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.tsx @@ -34,6 +34,8 @@ import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; import HeaderBreadcrumb from '../../components/common/HeaderBreadcrumb/HeaderBreadcrumb.component'; import { getGlossaryHomeCrumb } from '../../components/common/HeaderBreadcrumb/HeaderBreadcrumb.utils'; +import OntologyPicker from '../../components/governance/ontology/OntologyPicker/OntologyPicker'; +import OntologyStudioWorkspace from '../../components/governance/ontology/OntologyStudioWorkspace/OntologyStudioWorkspace'; import { useOntologyAiCapability } from '../../components/OntologyExplorer/hooks/useOntologyAiCapability'; import { OntologyEditLeaseState, @@ -56,13 +58,15 @@ import { UIPermission } from '../../context/PermissionProvider/PermissionProvide import { EntityType } from '../../enums/entity.enum'; import { ResourceEntity } from '../../enums/permissions.enum'; import { Glossary } from '../../generated/entity/data/glossary'; +import { Ontology } from '../../generated/entity/data/ontology'; import { RelationshipType } from '../../generated/entity/data/relationshipType'; import { Operation } from '../../generated/entity/policies/policy'; import { useAuth } from '../../hooks/authHooks'; import { useIsAiMode } from '../../hooks/useAppMode'; +import { getOntology } from '../../rest/ontologyAPI'; import { checkPermission } from '../../utils/PermissionsUtils'; import { generateUUID } from '../../utils/StringUtils'; -import { showInfoToast } from '../../utils/ToastUtils'; +import { showErrorToast, showInfoToast } from '../../utils/ToastUtils'; // 'review' has no tab; a memory's "view draft" link (?draft=) still opens it. type StudioMode = 'view' | 'edit' | 'query' | 'ai' | 'review'; @@ -385,6 +389,13 @@ function useLinkedDraftId(): string | undefined { const studioModeFor = (linkedDraftId?: string): StudioMode => linkedDraftId ? 'review' : 'view'; +const canCreateNativeOntology = ( + isAdminUser: boolean | undefined, + permissions: Parameters[2] +) => + Boolean(isAdminUser) || + checkPermission(Operation.Create, ResourceEntity.ONTOLOGY, permissions); + const OntologyExplorerPage: React.FC = () => { const { t } = useTranslation(); const isAiMode = useIsAiMode(); @@ -396,9 +407,65 @@ const OntologyExplorerPage: React.FC = () => { isLoading: isCapabilityLoading, } = useOntologyAiCapability(); const initialDraftId = useLinkedDraftId(); + const [searchParams, setSearchParams] = useSearchParams(); + const [loadedOntology, setLoadedOntology] = useState(); + const [isCreatingOntology, setIsCreatingOntology] = useState(false); const [mode, setMode] = useState(() => studioModeFor(initialDraftId) ); + const ontologyId = searchParams.get('ontology'); + const selectedOntology = + loadedOntology?.id === ontologyId ? loadedOntology : undefined; + const canCreateOntology = canCreateNativeOntology(isAdminUser, permissions); + const selectOntology = (ontology?: Ontology) => { + setLoadedOntology(ontology); + setIsCreatingOntology(false); + if ((ontology?.id ?? null) !== ontologyId) { + setMode('view'); + } + setSearchParams((current) => { + const next = new URLSearchParams(current); + if ((ontology?.id ?? null) !== current.get('ontology')) { + next.delete(DRAFT_SEARCH_PARAM); + } + if (ontology) { + next.set('ontology', ontology.id); + } else { + next.delete('ontology'); + } + + return next; + }); + }; + useEffect(() => { + if (!ontologyId) { + setLoadedOntology(undefined); + + return; + } + const controller = new AbortController(); + getOntology(ontologyId, 'owners,reviewers', controller.signal) + .then((ontology) => { + if (!controller.signal.aborted) { + setLoadedOntology(ontology); + } + }) + .catch((error) => { + if (!controller.signal.aborted) { + showErrorToast(error); + setMode('view'); + setSearchParams((current) => { + const next = new URLSearchParams(current); + next.delete('ontology'); + next.delete(DRAFT_SEARCH_PARAM); + + return next; + }); + } + }); + + return () => controller.abort(); + }, [ontologyId, setSearchParams]); const [viewSurface, setViewSurface] = useState('graph'); const [editSurface, setEditSurface] = useState('graph'); const [querySurface, setQuerySurface] = useState('console'); @@ -476,8 +543,18 @@ const OntologyExplorerPage: React.FC = () => { graphLeaseGlossary, glossaries ); + + function isGlossaryEditMode() { + return ( + !ontologyId && + !isCreatingOntology && + mode === 'edit' && + Boolean(leaseGlossary) + ); + } + const editLease = useOntologyEditLease({ - isActive: mode === 'edit' && Boolean(leaseGlossary), + isActive: isGlossaryEditMode(), resourceId: leaseGlossary?.id, resourceType: EntityType.GLOSSARY, }); @@ -667,11 +744,12 @@ const OntologyExplorerPage: React.FC = () => { + onAction={(key) => { + selectOntology(undefined); setSelectedGlossaryId( key === ALL_GLOSSARIES_KEY ? undefined : String(key) - ) - }> + ); + }}> { actions={ {renderGlossaryMenu()} - - - {renderPrimaryAction()} + {canCreateOntology && ( + + )} + {!selectedOntology && !isCreatingOntology && ( + <> + + + {renderPrimaryAction()} + + )} } breadcrumb={ @@ -829,7 +927,11 @@ const OntologyExplorerPage: React.FC = () => { } className="tw:mx-2 tw:mt-2 tw:shrink-0" data-testid="ontology-studio-header" - footer={renderModeTabs()} + footer={ + !selectedOntology && !isCreatingOntology + ? renderModeTabs() + : undefined + } subtitle={t('message.ontology-studio-description')} title={t('label.ontology-studio')} variant={isAiMode ? 'gradient' : 'flat'} @@ -976,6 +1078,30 @@ const OntologyExplorerPage: React.FC = () => { ); } + const renderWorkspace = () => { + if (ontologyId && !selectedOntology) { + return {t('label.loading')}; + } + if (selectedOntology || isCreatingOntology) { + return ( + + ); + } + + return ( + <> + {renderSubModeNav()} + {renderMainSection()} + + ); + }; + return ( { style={ONTOLOGY_STUDIO_STYLE}> {renderHeader()} - {renderSubModeNav()} - - {renderMainSection()} + {renderWorkspace()} {isLibraryOpen ? ( ({})); - -jest.mock('../../utils/PermissionsUtils', () => ({ - checkPermission: jest.fn(), -})); - -const mockCheckPermission = checkPermission as jest.MockedFunction< - typeof checkPermission ->; - -const t = ((key: string) => key) as TFunc; -const PERMISSIONS = {} as Parameters[2]; - -const GLOSSARY_A: Glossary = { - id: 'g-a', - name: 'Alpha', - displayName: 'A', - description: 'Alpha glossary', -}; -const GLOSSARY_B: Glossary = { - id: 'g-b', - name: 'Beta', - description: 'Beta glossary', -}; - -type EditLease = ReturnType; - -const buildLease = (overrides: Partial): EditLease => - ({ - isOwned: false, - lock: undefined, - state: 'idle', - ...overrides, - } as EditLease); - -describe('OntologyExplorerPage.utils', () => { - beforeEach(() => { - mockCheckPermission.mockReset(); - mockCheckPermission.mockReturnValue(false); - }); - - describe('getLeaseGlossary', () => { - it('uses the selected glossary for the graph and no draft', () => { - expect(getLeaseGlossary(GLOSSARY_A, GLOSSARY_B, undefined, [])).toEqual({ - graphLeaseGlossary: GLOSSARY_A, - leaseGlossary: GLOSSARY_A, - }); - }); - - it('falls back to the authoring glossary when nothing is selected', () => { - expect(getLeaseGlossary(undefined, GLOSSARY_B, undefined, [])).toEqual({ - graphLeaseGlossary: GLOSSARY_B, - leaseGlossary: GLOSSARY_B, - }); - }); - - it('prefers the authoring glossary for a concept draft', () => { - const result = getLeaseGlossary( - GLOSSARY_A, - GLOSSARY_B, - { id: 'draft', defaultGlossaryId: GLOSSARY_A.id }, - [GLOSSARY_A, GLOSSARY_B] - ); - - expect(result.leaseGlossary).toBe(GLOSSARY_B); - expect(result.graphLeaseGlossary).toBe(GLOSSARY_A); - }); - - it('resolves the draft default glossary when there is no authoring glossary', () => { - const result = getLeaseGlossary( - undefined, - undefined, - { id: 'draft', defaultGlossaryId: GLOSSARY_B.id }, - [GLOSSARY_A, GLOSSARY_B] - ); - - expect(result.leaseGlossary).toBe(GLOSSARY_B); - expect(result.graphLeaseGlossary).toBeUndefined(); - }); - }); - - describe('getLeaseOwnership', () => { - it('treats a lease without a lock as owned when the hook says so', () => { - expect( - getLeaseOwnership( - buildLease({ isOwned: true, state: 'owned' }), - GLOSSARY_A - ) - ).toEqual({ - editLeaseState: 'owned', - isLeaseForCurrentGlossary: true, - isLeaseOwned: true, - }); - }); - - it('reports acquiring when the owned lock targets another glossary', () => { - expect( - getLeaseOwnership( - buildLease({ - isOwned: true, - lock: { resourceId: GLOSSARY_B.id } as EditLease['lock'], - state: 'owned', - }), - GLOSSARY_A - ) - ).toEqual({ - editLeaseState: 'acquiring', - isLeaseForCurrentGlossary: false, - isLeaseOwned: false, - }); - }); - - it('keeps the hook state when the lease is not owned', () => { - expect( - getLeaseOwnership( - buildLease({ - isOwned: false, - lock: { resourceId: GLOSSARY_A.id } as EditLease['lock'], - state: 'contended', - }), - GLOSSARY_A - ) - ).toEqual({ - editLeaseState: 'contended', - isLeaseForCurrentGlossary: true, - isLeaseOwned: false, - }); - }); - }); - - describe('getOntologyPermissions', () => { - it('grants everything to admins without consulting permissions', () => { - expect(getOntologyPermissions(true, PERMISSIONS)).toEqual({ - canCreateConcept: true, - canEditOntology: true, - }); - expect(mockCheckPermission).not.toHaveBeenCalled(); - }); - - it('derives edit access from glossary permissions', () => { - mockCheckPermission.mockImplementation( - (operation) => operation === Operation.EditGlossaryTerms - ); - - expect(getOntologyPermissions(false, PERMISSIONS)).toEqual({ - canCreateConcept: true, - canEditOntology: true, - }); - expect(mockCheckPermission).toHaveBeenCalledWith( - Operation.EditAll, - ResourceEntity.GLOSSARY, - PERMISSIONS - ); - }); - - it('allows concept creation from the glossary term create permission alone', () => { - mockCheckPermission.mockImplementation( - (operation, resource) => - operation === Operation.Create && - resource === ResourceEntity.GLOSSARY_TERM - ); - - expect(getOntologyPermissions(undefined, PERMISSIONS)).toEqual({ - canCreateConcept: true, - canEditOntology: false, - }); - }); - - it('denies both when no permission matches', () => { - expect(getOntologyPermissions(false, PERMISSIONS)).toEqual({ - canCreateConcept: false, - canEditOntology: false, - }); - }); - }); - - describe('getModeTabs', () => { - it('always exposes view and query', () => { - expect(getModeTabs(false, false, t).map((tab) => tab.id)).toEqual([ - 'view', - 'query', - ]); - }); - - it('adds edit and ai tabs when enabled', () => { - expect(getModeTabs(true, true, t)).toEqual([ - { id: 'view', label: 'label.view' }, - { id: 'edit', label: 'label.edit' }, - { id: 'query', label: 'label.query' }, - { id: 'ai', label: 'label.ai' }, - ]); - }); - }); - - describe('getSubModeConfiguration', () => { - const surfaces = { - editSurface: 'model' as const, - querySurface: 'builder' as const, - viewSurface: 'tree' as const, - }; - - it('builds the view configuration', () => { - expect(getSubModeConfiguration('view', surfaces, false, t)).toEqual({ - id: 'tree', - items: [ - { id: 'graph', label: 'label.graph' }, - { id: 'tree', label: 'label.tree' }, - ], - label: 'label.explore', - }); - }); - - it('builds the edit configuration', () => { - expect(getSubModeConfiguration('edit', surfaces, false, t)).toEqual({ - id: 'model', - items: [ - { id: 'graph', label: 'label.graph' }, - { id: 'model', label: 'label.model' }, - ], - label: 'label.author', - }); - }); - - it('exposes query surfaces only when rdf is enabled', () => { - expect(getSubModeConfiguration('query', surfaces, true, t)).toEqual({ - id: 'builder', - items: [ - { id: 'console', label: 'label.sparql-console' }, - { id: 'builder', label: 'label.visual-builder' }, - ], - label: 'label.query', - }); - expect( - getSubModeConfiguration('query', surfaces, false, t).items - ).toEqual([]); - }); - - it('builds the ai configuration', () => { - expect(getSubModeConfiguration('ai', surfaces, true, t)).toEqual({ - id: 'ai', - items: [], - label: 'label.ontology-ai-assistant', - }); - }); - }); - - describe('label helpers', () => { - it('picks display name, then name, then the all-glossaries label', () => { - expect(getSelectedGlossaryLabel(GLOSSARY_A, 'all')).toBe('A'); - expect(getSelectedGlossaryLabel(GLOSSARY_B, 'all')).toBe('Beta'); - expect(getSelectedGlossaryLabel(undefined, 'all')).toBe('all'); - }); - - it('picks the user display name, name, or generic label', () => { - expect(getUserName({ displayName: 'Jane', name: 'jane' }, t)).toBe( - 'Jane' - ); - expect(getUserName({ name: 'jane' }, t)).toBe('jane'); - expect(getUserName(undefined, t)).toBe('label.user'); - }); - }); - - describe('surface helpers', () => { - it('only honours the view surface in view mode', () => { - expect(getExplorerSurface('view', 'tree')).toBe('tree'); - expect(getExplorerSurface('edit', 'tree')).toBe('graph'); - }); - - it('uses the secondary background for query and ai modes', () => { - expect(getContentSectionClassName('query')).toContain('tw:bg-secondary'); - expect(getContentSectionClassName('ai')).toContain('tw:bg-secondary'); - expect(getContentSectionClassName('view')).toContain('tw:bg-primary'); - }); - }); -}); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.utils.ts b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.utils.ts deleted file mode 100644 index f8fefb971580..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.utils.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright 2024 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import classNames from 'classnames'; -import { useOntologyEditLease } from '../../components/OntologyExplorer/hooks/useOntologyEditLease'; -import { ResourceEntity } from '../../enums/permissions.enum'; -import { Glossary } from '../../generated/entity/data/glossary'; -import { Operation } from '../../generated/entity/policies/policy'; -import { checkPermission } from '../../utils/PermissionsUtils'; -import { - EditSurface, - QuerySurface, - StudioMode, - StudioModeTab, - StudioSubMode, - ViewSurface, -} from './OntologyExplorerPage'; -import { TFunc } from './OntologyExplorerPage.interface'; - -export const getLeaseGlossary = ( - selectedGlossary: Glossary | undefined, - authoringGlossary: Glossary | undefined, - conceptDraft: { defaultGlossaryId?: string; id: string } | undefined, - glossaries: Glossary[] -) => { - const graphLeaseGlossary = selectedGlossary ?? authoringGlossary; - const leaseGlossary = conceptDraft - ? authoringGlossary ?? - glossaries.find( - (glossary) => glossary.id === conceptDraft.defaultGlossaryId - ) - : graphLeaseGlossary; - - return { graphLeaseGlossary, leaseGlossary }; -}; - -export const getLeaseOwnership = ( - editLease: ReturnType, - leaseGlossary: Glossary | undefined -) => { - const isLeaseForCurrentGlossary = - !editLease.lock || editLease.lock.resourceId === leaseGlossary?.id; - const isLeaseOwned = editLease.isOwned && isLeaseForCurrentGlossary; - const editLeaseState = - editLease.isOwned && !isLeaseForCurrentGlossary - ? ('acquiring' as const) - : editLease.state; - - return { editLeaseState, isLeaseForCurrentGlossary, isLeaseOwned }; -}; - -export const getOntologyPermissions = ( - isAdminUser: boolean | undefined, - permissions: Parameters[2] -) => { - const canEditOntology = - Boolean(isAdminUser) || - checkPermission(Operation.EditAll, ResourceEntity.GLOSSARY, permissions) || - checkPermission( - Operation.EditGlossaryTerms, - ResourceEntity.GLOSSARY, - permissions - ) || - checkPermission( - Operation.EditEntityRelationship, - ResourceEntity.GLOSSARY_TERM, - permissions - ); - const canCreateConcept = - canEditOntology || - Boolean(isAdminUser) || - checkPermission( - Operation.Create, - ResourceEntity.GLOSSARY_TERM, - permissions - ); - - return { canCreateConcept, canEditOntology }; -}; - -export const getModeTabs = ( - isOntologyAiEnabled: boolean, - canEditOntology: boolean, - t: TFunc -): StudioModeTab[] => { - const aiModeTabs: StudioModeTab[] = isOntologyAiEnabled - ? [{ id: 'ai', label: t('label.ai') }] - : []; - const editModeTabs: StudioModeTab[] = canEditOntology - ? [{ id: 'edit', label: t('label.edit') }] - : []; - - return [ - { id: 'view', label: t('label.view') }, - ...editModeTabs, - { id: 'query', label: t('label.query') }, - ...aiModeTabs, - ]; -}; - -const SUB_MODE_CONFIGURATION_BUILDERS: Record< - StudioMode, - ( - surfaces: { - viewSurface: ViewSurface; - editSurface: EditSurface; - querySurface: QuerySurface; - }, - isRdfEnabled: boolean, - t: TFunc - ) => StudioSubMode -> = { - view: ({ viewSurface }, _isRdfEnabled, t) => ({ - id: viewSurface, - items: [ - { id: 'graph', label: t('label.graph') }, - { id: 'tree', label: t('label.tree') }, - ], - label: t('label.explore'), - }), - edit: ({ editSurface }, _isRdfEnabled, t) => ({ - id: editSurface, - items: [ - { id: 'graph', label: t('label.graph') }, - { id: 'model', label: t('label.model') }, - ], - label: t('label.author'), - }), - query: ({ querySurface }, isRdfEnabled, t) => ({ - id: querySurface, - items: isRdfEnabled - ? [ - { id: 'console', label: t('label.sparql-console') }, - { id: 'builder', label: t('label.visual-builder') }, - ] - : [], - label: t('label.query'), - }), - ai: (_surfaces, _isRdfEnabled, t) => ({ - id: 'ai', - items: [], - label: t('label.ontology-ai-assistant'), - }), -}; - -export const getSubModeConfiguration = ( - mode: StudioMode, - surfaces: { - viewSurface: ViewSurface; - editSurface: EditSurface; - querySurface: QuerySurface; - }, - isRdfEnabled: boolean, - t: TFunc -): StudioSubMode => - SUB_MODE_CONFIGURATION_BUILDERS[mode](surfaces, isRdfEnabled, t); - -export const getSelectedGlossaryLabel = ( - selectedGlossary: Glossary | undefined, - allGlossariesLabel: string -): string => - selectedGlossary?.displayName ?? selectedGlossary?.name ?? allGlossariesLabel; - -export const getUserName = ( - currentUser: { displayName?: string; name?: string } | undefined, - t: TFunc -): string => currentUser?.displayName ?? currentUser?.name ?? t('label.user'); - -export const getExplorerSurface = ( - mode: StudioMode, - viewSurface: ViewSurface -): 'graph' | 'tree' => (mode === 'view' ? viewSurface : 'graph'); - -export const getContentSectionClassName = (mode: StudioMode): string => - classNames( - 'tw:flex tw:min-h-0 tw:flex-1', - mode === 'query' || mode === 'ai' ? 'tw:bg-secondary' : 'tw:bg-primary' - ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/entity/tasks/TaskDetailsPage/TaskDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/entity/tasks/TaskDetailsPage/TaskDetailsPage.tsx new file mode 100644 index 000000000000..84a299b6579d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/entity/tasks/TaskDetailsPage/TaskDetailsPage.tsx @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Card, PageLayout } from '@openmetadata/ui-core-components'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import DocumentTitle from '../../../../components/common/DocumentTitle/DocumentTitle'; +import TaskDetailPanel from '../../../../components/discovery/personal-space/InboxPage/components/TaskDetailPanel'; + +const TaskDetailsPage = () => { + const { taskId } = useParams<{ taskId: string }>(); + const { t } = useTranslation(); + + return ( + + + + + + {taskId && } + + + + ); +}; + +export default TaskDetailsPage; diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/ontologyAPI.test.ts b/openmetadata-ui/src/main/resources/ui/src/rest/ontologyAPI.test.ts index 79eddd924fe9..a73aad7d1d2d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/ontologyAPI.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/ontologyAPI.test.ts @@ -11,24 +11,34 @@ * limitations under the License. */ +import { OntologyConceptType } from '../generated/api/data/createOntologyConcept'; import { Operation } from '../generated/api/data/ontologyBulkRequest'; import { OntologyChangeSetCommand } from '../generated/api/data/ontologyChangeSetCommand'; import { OntologyChangeSetState } from '../generated/entity/data/ontologyChangeSet'; import APIClient from './axiosClient'; import { + activateOntologyRelease, + captureOntologyRelease, + createOntology, + createOntologyConcept, generateOntologyDomainDraft, generateOntologySparql, getMemoryOntologyProposalStatus, getOntologyBulkTemplate, getOntologyChangeSet, + getOntologyRelease, getRdfEntityDiff, installOntologyPack, listInferenceRules, + listOntologies, listOntologyBulkJobs, listOntologyChangeSets, + listOntologyConcepts, listOntologyPacks, + listOntologyReleases, listRelationshipTypes, materializeInferenceRules, + patchOntologyConcept, proposeTermFromMemory, releaseOntologyEditLock, submitOntologyBulkOperation, @@ -50,7 +60,136 @@ const mockedApiClient = APIClient as jest.Mocked; describe('ontologyAPI', () => { beforeEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + it('creates native ontology definitions and patches by identity', async () => { + const ontology = { + name: 'Sales', + iri: 'urn:sales', + namespace: 'urn:sales:', + }; + const concept = { + name: 'Customer', + ontology: 'Sales', + conceptType: OntologyConceptType.OwlClass, + }; + mockedApiClient.post.mockResolvedValue({ data: { id: 'created' } }); + mockedApiClient.patch.mockResolvedValue({ data: { id: 'updated' } }); + + expect(await createOntology(ontology)).toEqual({ id: 'created' }); + expect(mockedApiClient.post).toHaveBeenLastCalledWith( + '/ontologies', + ontology + ); + expect(await createOntologyConcept(concept)).toEqual({ id: 'created' }); + expect(mockedApiClient.post).toHaveBeenLastCalledWith( + '/ontologyConcepts', + concept + ); + + const patch = [{ op: 'test' as const, path: '/version', value: 0.1 }]; + + expect(await patchOntologyConcept('concept/id', patch)).toEqual({ + id: 'updated', + }); + expect(mockedApiClient.patch).toHaveBeenCalledWith( + '/ontologyConcepts/concept%2Fid', + patch, + { + headers: { 'Content-Type': 'application/json-patch+json' }, + } + ); + }); + + it('browses sealed releases by native owner and reads a selected release', async () => { + const release = { id: 'release-id', ontology: { id: 'ontology-id' } }; + mockedApiClient.get + .mockResolvedValueOnce({ + data: { data: [release], paging: { total: 1 } }, + }) + .mockResolvedValueOnce({ data: release }); + const signal = new AbortController().signal; + + await listOntologyReleases( + 'ontology-id', + { limit: 25, after: 'cursor' }, + signal + ); + + expect(mockedApiClient.get).toHaveBeenLastCalledWith('/ontologyReleases', { + params: { ontologyId: 'ontology-id', limit: 25, after: 'cursor' }, + signal, + }); + expect(await getOntologyRelease('release-id', signal)).toEqual(release); + expect(mockedApiClient.get).toHaveBeenLastCalledWith( + '/ontologyReleases/release-id', + { signal } + ); + }); + + it('uses dedicated ontology collections and preserves cursor paging', async () => { + const response = { + data: { data: [], paging: { total: 150, after: 'next' } }, + }; + mockedApiClient.get.mockResolvedValue(response); + const signal = new AbortController().signal; + + expect(await listOntologies({ limit: 25, after: 'first' }, signal)).toEqual( + response.data + ); + expect(mockedApiClient.get).toHaveBeenLastCalledWith('/ontologies', { + params: { limit: 25, after: 'first' }, + signal, + }); + + await listOntologyConcepts( + { ontology: 'Sales', fields: 'superClasses,subClasses', limit: 25 }, + signal + ); + + expect(mockedApiClient.get).toHaveBeenLastCalledWith('/ontologyConcepts', { + params: { + ontology: 'Sales', + fields: 'superClasses,subClasses', + limit: 25, + }, + signal, + }); + }); + + it('captures the selected working revision', async () => { + const request = { + expectedWorkingRevision: 7, + }; + const release = { id: 'candidate', contentDigest: 'sealed' }; + mockedApiClient.post.mockResolvedValue({ data: release }); + + expect(await captureOntologyRelease('ontology-id', request)).toEqual( + release + ); + expect(mockedApiClient.post).toHaveBeenCalledWith( + '/ontologies/ontology-id/releases', + request + ); + }); + + it('preserves activation conflicts for a reload instead of retrying with a newer revision', async () => { + const conflict = new Error('Activation changed'); + const request = { + releaseId: 'approved-release', + expectedActivationRevision: 2, + }; + mockedApiClient.put.mockRejectedValue(conflict); + + await expect(activateOntologyRelease('ontology-id', request)).rejects.toBe( + conflict + ); + expect(mockedApiClient.put).toHaveBeenCalledTimes(1); + expect(mockedApiClient.put).toHaveBeenCalledWith( + '/ontologies/ontology-id/activation', + request + ); }); it('lists governed relationship types with paging parameters', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/ontologyAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/ontologyAPI.ts index e292e837e60e..41e2811c6fd3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/ontologyAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/ontologyAPI.ts @@ -21,10 +21,15 @@ import { InferenceRuleStatus, } from '../generated/api/configuration/rdf/inferenceRuleStatus'; import { AcquireOntologyEditLock } from '../generated/api/data/acquireOntologyEditLock'; +import { ActivateOntologyRelease } from '../generated/api/data/activateOntologyRelease'; import { ApplyOntologyChangeSet } from '../generated/api/data/applyOntologyChangeSet'; import { BuildOntologySubset } from '../generated/api/data/buildOntologySubset'; +import { CaptureOntologyRelease } from '../generated/api/data/captureOntologyRelease'; +import { CreateOntology } from '../generated/api/data/createOntology'; import { CreateOntologyAxiom } from '../generated/api/data/createOntologyAxiom'; import { CreateOntologyChangeSet } from '../generated/api/data/createOntologyChangeSet'; +import { CreateOntologyConcept } from '../generated/api/data/createOntologyConcept'; +import { CreateOntologyProperty } from '../generated/api/data/createOntologyProperty'; import { CreateRelationshipType } from '../generated/api/data/createRelationshipType'; import { DeleteOntologyResource } from '../generated/api/data/deleteOntologyResource'; import { InstallOntologyPack } from '../generated/api/data/installOntologyPack'; @@ -62,26 +67,42 @@ import { OntologyPatternList } from '../generated/api/data/ontologyPatternList'; import { OntologyProfileReport } from '../generated/api/data/ontologyProfileReport'; import { OntologyRelationshipSuggestionList } from '../generated/api/data/ontologyRelationshipSuggestionList'; import { OntologyRelationshipSuggestionRequest } from '../generated/api/data/ontologyRelationshipSuggestionRequest'; +import type { OntologyReleaseDiff } from '../generated/api/data/ontologyReleaseDiff'; +import type { OntologyReleaseStructureReport } from '../generated/api/data/ontologyReleaseStructureReport'; import { OntologyStructuralDiff } from '../generated/api/data/ontologyStructuralDiff'; import { OntologyStructuralDiffRequest } from '../generated/api/data/ontologyStructuralDiffRequest'; import { OntologyStructuralMergeResult } from '../generated/api/data/ontologyStructuralMergeResult'; import { OntologySubsetResult } from '../generated/api/data/ontologySubsetResult'; import { RDFEntityDiff } from '../generated/api/data/rdfEntityDiff'; import { UpdateOntologyChangeSet } from '../generated/api/data/updateOntologyChangeSet'; +import type { Ontology } from '../generated/entity/data/ontology'; import { OntologyAxiom } from '../generated/entity/data/ontologyAxiom'; import { OntologyChangeSet, OntologyChangeSetState, } from '../generated/entity/data/ontologyChangeSet'; +import type { OntologyConcept } from '../generated/entity/data/ontologyConcept'; +import type { OntologyProperty } from '../generated/entity/data/ontologyProperty'; +import type { OntologyRelease } from '../generated/entity/data/ontologyRelease'; import { RelationshipType } from '../generated/entity/data/relationshipType'; import { BackgroundJob } from '../generated/jobs/backgroundJob'; import { OntologyEditLock } from '../generated/type/ontologyEditLock'; +import { + MemberKind, + OntologyReleaseMember, +} from '../generated/type/ontologyReleaseMember'; +import { OntologyState } from '../generated/type/ontologyState'; import { ListParams } from '../interface/API.interface'; import { getEncodedFqn } from '../utils/StringUtils'; import APIClient from './axiosClient'; +import { EXPORT_FORMAT_TO_ACCEPT_HEADER, OntologyExportFormat } from './rdfAPI'; const RELATIONSHIP_TYPES_PATH = '/relationshipTypes'; +const ONTOLOGIES_PATH = '/ontologies'; +const ONTOLOGY_CONCEPTS_PATH = '/ontologyConcepts'; +const ONTOLOGY_PROPERTIES_PATH = '/ontologyProperties'; const ONTOLOGY_AXIOMS_PATH = '/ontologyAxioms'; +const ONTOLOGY_RELEASES_PATH = '/ontologyReleases'; const CHANGE_SETS_PATH = '/ontologyChangeSets'; const EDIT_LOCKS_PATH = '/ontologyEditLocks'; const INFERENCE_RULES_PATH = '/rdf/rules'; @@ -95,10 +116,275 @@ const ONTOLOGY_REASONING_PATH = '/ontology/reasoning'; const ONTOLOGY_STRUCTURE_PATH = '/ontology/structure'; const ONTOLOGY_SUBSETS_PATH = '/ontology/subsets'; +const JSON_PATCH_CONTENT_TYPE = 'application/json-patch+json'; + +export const createOntology = async (request: CreateOntology) => { + const response = await APIClient.post(ONTOLOGIES_PATH, request); + + return response.data; +}; + +export const patchOntology = async (id: string, patch: Operation[]) => { + const response = await APIClient.patch( + `${ONTOLOGIES_PATH}/${encodeURIComponent(id)}`, + patch, + { headers: { 'Content-Type': JSON_PATCH_CONTENT_TYPE } } + ); + + return response.data; +}; + +export const createOntologyConcept = async (request: CreateOntologyConcept) => { + const response = await APIClient.post( + ONTOLOGY_CONCEPTS_PATH, + request + ); + + return response.data; +}; + +export const patchOntologyConcept = async (id: string, patch: Operation[]) => { + const response = await APIClient.patch( + `${ONTOLOGY_CONCEPTS_PATH}/${encodeURIComponent(id)}`, + patch, + { headers: { 'Content-Type': JSON_PATCH_CONTENT_TYPE } } + ); + + return response.data; +}; + +export const createOntologyProperty = async ( + request: CreateOntologyProperty +) => { + const response = await APIClient.post( + ONTOLOGY_PROPERTIES_PATH, + request + ); + + return response.data; +}; + +export const patchOntologyProperty = async (id: string, patch: Operation[]) => { + const response = await APIClient.patch( + `${ONTOLOGY_PROPERTIES_PATH}/${encodeURIComponent(id)}`, + patch, + { headers: { 'Content-Type': JSON_PATCH_CONTENT_TYPE } } + ); + + return response.data; +}; + +export const listOntologyProperties = async ( + params?: ListParams & { ontology?: string }, + signal?: AbortSignal +) => { + const response = await APIClient.get>( + ONTOLOGY_PROPERTIES_PATH, + { params, signal } + ); + + return response.data; +}; + +export const listOntologies = async ( + params?: ListParams, + signal?: AbortSignal +) => { + const response = await APIClient.get>( + ONTOLOGIES_PATH, + { params, signal } + ); + + return response.data; +}; + +export const getOntology = async ( + ontologyId: string, + fields?: string, + signal?: AbortSignal +) => { + const response = await APIClient.get( + `${ONTOLOGIES_PATH}/${encodeURIComponent(ontologyId)}`, + { params: { fields }, signal } + ); + + return response.data; +}; + +export const listOntologyConcepts = async ( + params?: ListParams & { ontology?: string }, + signal?: AbortSignal +) => { + const response = await APIClient.get>( + ONTOLOGY_CONCEPTS_PATH, + { params, signal } + ); + + return response.data; +}; + +export const captureOntologyRelease = async ( + ontologyId: string, + request: CaptureOntologyRelease +) => { + const response = await APIClient.post( + `${ONTOLOGIES_PATH}/${encodeURIComponent(ontologyId)}/releases`, + request + ); + + return response.data; +}; + +export const activateOntologyRelease = async ( + ontologyId: string, + request: ActivateOntologyRelease +) => { + const response = await APIClient.put( + `${ONTOLOGIES_PATH}/${encodeURIComponent(ontologyId)}/activation`, + request + ); + + return response.data; +}; + +export const getOntologyState = async ( + ontologyId: string, + signal?: AbortSignal +) => { + const response = await APIClient.get( + `${ONTOLOGIES_PATH}/${encodeURIComponent(ontologyId)}/state`, + { signal } + ); + + return response.data; +}; + +export const listOntologyReleaseMembers = async ( + releaseId: string, + kind: MemberKind, + params?: Pick, + signal?: AbortSignal +) => { + const response = await APIClient.get>( + `${ONTOLOGY_RELEASES_PATH}/${encodeURIComponent( + releaseId + )}/members/${kind}`, + { params, signal } + ); + + return response.data; +}; + +export const listOntologyReleases = async ( + ontologyId: string, + params?: Pick, + signal?: AbortSignal +) => { + const response = await APIClient.get>( + ONTOLOGY_RELEASES_PATH, + { params: { ...params, ontologyId }, signal } + ); + + return response.data; +}; + +export const getOntologyRelease = async ( + releaseId: string, + signal?: AbortSignal +) => { + const response = await APIClient.get( + `${ONTOLOGY_RELEASES_PATH}/${encodeURIComponent(releaseId)}`, + { signal } + ); + + return response.data; +}; + +export const exportOntologyRelease = async ( + releaseId: string, + format: OntologyExportFormat +) => { + const response = await APIClient.get( + `${ONTOLOGY_RELEASES_PATH}/${encodeURIComponent(releaseId)}/export`, + { + params: { format }, + headers: { Accept: EXPORT_FORMAT_TO_ACCEPT_HEADER[format] }, + responseType: 'text', + } + ); + + return response.data; +}; + type OntologyChangeSetCreation = | CreateOntologyChangeSet | CreateOntologyChangeSetRequest; +export type OntologyReviewDefinition = + | Ontology + | OntologyConcept + | OntologyProperty + | OntologyAxiom + | OntologyRelease; + +export const getOntologyReleaseVersion = async ( + id: string, + version: number, + signal?: AbortSignal +): Promise => { + if (!Number.isFinite(version) || version <= 0) { + throw new Error('Invalid ontology review target'); + } + const response = await APIClient.get( + `${ONTOLOGY_RELEASES_PATH}/${encodeURIComponent(id)}/versions/${version}`, + { signal } + ); + if (response.data.id !== id || response.data.version !== version) { + throw new Error( + 'Ontology history does not match the submitted review target' + ); + } + + return response.data; +}; + +export const getOntologyReleaseMember = async ( + releaseId: string, + kind: MemberKind, + memberId: string, + signal?: AbortSignal +) => { + const response = await APIClient.get( + `${ONTOLOGY_RELEASES_PATH}/${encodeURIComponent( + releaseId + )}/members/${kind}/${encodeURIComponent(memberId)}`, + { signal } + ); + + return response.data; +}; + +export const getOntologyReleaseStructure = async (id: string) => { + const response = await APIClient.get( + `${ONTOLOGY_RELEASES_PATH}/${id}/structure` + ); + + return response.data; +}; + +export const getOntologyReleaseDiff = async ( + id: string, + params: { baseReleaseId?: string; limit?: number; after?: string } = {}, + signal?: AbortSignal +) => { + const response = await APIClient.get( + `${ONTOLOGY_RELEASES_PATH}/${id}/diff`, + { params, signal } + ); + + return response.data; +}; + export const proposeTermFromMemory = async (memoryId: string) => { const response = await APIClient.post< OntologyMemoryDerivationRequest, @@ -169,12 +455,13 @@ export const deleteRelationshipType = async (id: string) => { }; export const listOntologyAxioms = async ( - glossary: string, - params?: ListParams + ontologyId: string, + params?: ListParams, + signal?: AbortSignal ) => { const response = await APIClient.get>( ONTOLOGY_AXIOMS_PATH, - { params: { ...params, glossary } } + { params: { ...params, ontologyId }, signal } ); return response.data; @@ -198,6 +485,16 @@ export const createOntologyAxiom = async (request: CreateOntologyAxiom) => { return response.data; }; +export const patchOntologyAxiom = async (id: string, patch: Operation[]) => { + const response = await APIClient.patch( + `${ONTOLOGY_AXIOMS_PATH}/${encodeURIComponent(id)}`, + patch, + { headers: { 'Content-Type': JSON_PATCH_CONTENT_TYPE } } + ); + + return response.data; +}; + export const listOntologyChangeSets = async ( params?: ListParams & { // Several states are sent comma-separated, which the server accepts. diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/rdfAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/rdfAPI.ts index d52ac470d8cd..728869a07fa7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/rdfAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/rdfAPI.ts @@ -243,6 +243,15 @@ export const runGlossarySparqlQuery = ( ): Promise => executeSparqlQuery(`/glossaries/${glossaryId}/sparql`, params); +export const runOntologySparqlQuery = ( + ontologyId: string, + params: SparqlPlaygroundParams +): Promise => + executeSparqlQuery( + `/ontologies/${encodeURIComponent(ontologyId)}/sparql`, + params + ); + export const EXPORT_FORMAT_TO_ACCEPT_HEADER: Record = { jsonld: MIME_APPLICATION_LD_JSON, turtle: MIME_TEXT_TURTLE, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/DeleteWidget/DeleteWidgetClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/DeleteWidget/DeleteWidgetClassBase.ts index c03961b5e009..9555ea797ec3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/DeleteWidget/DeleteWidgetClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/DeleteWidget/DeleteWidgetClassBase.ts @@ -18,6 +18,7 @@ import i18n from '../i18next/LocalUtil'; const ENTITY_TYPE_PATH_MAP: Record = { [EntityType.GLOSSARY]: 'glossaries', [EntityType.GLOSSARY_TERM]: 'glossaryTerms', + [EntityType.ONTOLOGY_PROPERTY]: 'ontologyProperties', [EntityType.POLICY]: 'policies', [EntityType.KPI]: EntityType.KPI, [EntityType.DASHBOARD_DATA_MODEL]: 'dashboard/datamodels', diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityNameUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/EntityNameUtils.ts index ef240b0a43ed..9f5eaa1b74b1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityNameUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityNameUtils.ts @@ -152,6 +152,10 @@ export const getPluralizeEntityName = (entityType?: string) => { }; export const EntityTypeName: Record = { + [EntityType.ONTOLOGY]: t('label.ontology'), + [EntityType.ONTOLOGY_CONCEPT]: t('label.concept'), + [EntityType.ONTOLOGY_PROPERTY]: t('label.property'), + [EntityType.ONTOLOGY_AXIOM]: t('label.constraint'), [EntityType.API_SERVICE]: t('label.api-service'), [EntityType.DATABASE_SERVICE]: t('label.database-service'), [EntityType.MESSAGING_SERVICE]: t('label.messaging-service'), diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TaskNavigationUtils.knowledgePage.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TaskNavigationUtils.knowledgePage.test.ts index 8f82b5ca04b5..9ba2143405cd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TaskNavigationUtils.knowledgePage.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TaskNavigationUtils.knowledgePage.test.ts @@ -11,7 +11,17 @@ * limitations under the License. */ -import { getKnowledgeCenterPagePath } from './TaskNavigationUtils'; +import { + Task, + TaskCategory, + TaskStatus, + TaskType, +} from '../generated/entity/tasks/task'; +import { + getKnowledgeCenterPagePath, + getTaskDetailPathFromTask, +} from './TaskNavigationUtils'; +import { getTaskDetailPath } from './TaskUtils'; describe('getKnowledgeCenterPagePath', () => { it('points at the Context Center article route', () => { @@ -28,3 +38,24 @@ describe('getKnowledgeCenterPagePath', () => { ).toBe('/context-center/articles/Onboarding.My%20Runbook/tasks/open'); }); }); + +describe('native ontology review routes', () => { + it('opens the release review task', () => { + const task: Task = { + id: 'task-id', + name: 'Review', + type: TaskType.RequestApproval, + status: TaskStatus.Open, + category: TaskCategory.Approval, + createdBy: { id: 'author-id', type: 'user' }, + about: { + id: 'definition-id', + type: 'ontologyRelease', + fullyQualifiedName: 'Organization.Customer', + }, + }; + + expect(getTaskDetailPathFromTask(task)).toBe('/tasks/task-id'); + expect(getTaskDetailPath(task)).toBe('/tasks/task-id'); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TaskNavigationUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TaskNavigationUtils.ts index 78b73c39bb91..72b2c14f39cf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TaskNavigationUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TaskNavigationUtils.ts @@ -27,6 +27,7 @@ import { getUserPath, } from './RouterUtils'; import { getEncodedFqn } from './StringUtils'; +import { getTaskDetailPath, isOntologyReviewTask } from './TaskUtils'; export const TASK_ENTITIES = [ EntityType.TABLE, @@ -97,10 +98,12 @@ export const getEntityTasksPath = ( }; export const getTaskDetailPathFromTask = (task: TaskEntity) => - getEntityTasksPath( - (task.about?.type as EntityType) ?? '', - task.about?.fullyQualifiedName ?? '' - ); + isOntologyReviewTask(task) + ? getTaskDetailPath(task) + : getEntityTasksPath( + (task.about?.type as EntityType) ?? '', + task.about?.fullyQualifiedName ?? '' + ); /** * Where a task's asset lives, for a task that may not name one: an incident diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TaskUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TaskUtils.ts index 8869ef265953..d09344f0bf9c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TaskUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TaskUtils.ts @@ -11,7 +11,32 @@ * limitations under the License. */ import { EntityType } from '../enums/entity.enum'; -import { Task, TaskEntityStatus } from '../rest/tasksAPI'; +import { ResolveTask } from '../generated/api/tasks/resolveTask'; +import { + Task, + TaskStatus as TaskEntityStatus, + TaskType, +} from '../generated/entity/tasks/task'; + +export const isOntologyReviewTask = (task: Task): boolean => + task.type === TaskType.RequestApproval && + task.about?.type === 'ontologyRelease'; + +export const getTaskResolveBody = ( + task: Task, + body: ResolveTask +): ResolveTask => { + if (!isOntologyReviewTask(task)) { + return body; + } + + // A native review votes on server-captured content; it cannot edit that content. + return { + transitionId: body.transitionId, + resolutionType: body.resolutionType, + comment: body.comment, + }; +}; /** * Check if a task is closed (any terminal status) @@ -50,7 +75,7 @@ export const getTaskDetailPath = (task: Task): string => { const entityType = getTaskEntityType(task); const entityFQN = getTaskEntityFQN(task); - if (!entityType || !entityFQN) { + if (!entityType || !entityFQN || isOntologyReviewTask(task)) { return `/tasks/${task.id}`; } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/governance/ontology/OntologyAuthoring.utils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/governance/ontology/OntologyAuthoring.utils.ts new file mode 100644 index 000000000000..d41b7a949413 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/governance/ontology/OntologyAuthoring.utils.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const isAbsoluteOntologyIri = (value: string): boolean => { + try { + return Boolean(new URL(value).protocol); + } catch { + return false; + } +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/governance/ontology/OntologyGraph.utils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/governance/ontology/OntologyGraph.utils.test.ts new file mode 100644 index 000000000000..6e22e42c7ed9 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/governance/ontology/OntologyGraph.utils.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { buildOntologyTreeGroups } from '../../../components/OntologyExplorer/OntologyStudio.utils'; +import { Ontology } from '../../../generated/entity/data/ontology'; +import { + OntologyConcept, + OntologyConceptType, +} from '../../../generated/entity/data/ontologyConcept'; +import { buildNativeOntologyGraph } from './OntologyGraph.utils'; + +const ontology: Ontology = { + id: 'sales', + name: 'Sales', + fullyQualifiedName: 'Sales', + iri: 'urn:sales', + namespace: 'urn:sales:', +}; +const concept = ( + name: string, + superClasses: string[] = [] +): OntologyConcept => ({ + id: name, + name, + fullyQualifiedName: `Sales.${name}`, + conceptType: OntologyConceptType.OwlClass, + iri: `urn:${name}`, + ontology: { id: ontology.id, type: 'ontology' }, + superClasses, +}); + +it('projects native subclass axioms into the existing graph and tree without glossary entities', () => { + const graph = buildNativeOntologyGraph(ontology, [ + concept('Customer', ['urn:Party']), + concept('Party'), + ]); + + expect(graph.nodes.every((node) => node.type === 'ontologyConcept')).toBe( + true + ); + expect(graph.edges).toEqual([ + expect.objectContaining({ + from: 'Customer', + to: 'Party', + relationType: 'subClassOf', + }), + ]); + + const groups = buildOntologyTreeGroups( + graph, + { + viewMode: 'hierarchy', + glossaryIds: [], + relationTypes: [], + searchQuery: '', + showIsolatedNodes: true, + showCrossGlossaryOnly: false, + }, + [], + [] + ); + + expect(groups[0].glossaryName).toBe('Sales'); + expect(groups[0].rows.map((row) => [row.node.id, row.depth])).toEqual([ + ['Party', 0], + ['Customer', 1], + ]); +}); + +it('does not draw edges to concepts outside the loaded page', () => { + expect( + buildNativeOntologyGraph(ontology, [concept('Customer', ['urn:Party'])]) + .edges + ).toEqual([]); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/governance/ontology/OntologyGraph.utils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/governance/ontology/OntologyGraph.utils.ts new file mode 100644 index 000000000000..d31e2355c418 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/governance/ontology/OntologyGraph.utils.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + OntologyEdge, + OntologyGraphData, +} from '../../../components/OntologyExplorer/OntologyExplorer.interface'; +import { Ontology } from '../../../generated/entity/data/ontology'; +import { OntologyConcept } from '../../../generated/entity/data/ontologyConcept'; +import { getEntityName } from '../../EntityNameUtils'; + +export const buildNativeOntologyGraph = ( + ontology: Ontology, + concepts: OntologyConcept[] +): OntologyGraphData => { + const byIri = new Map(concepts.map((concept) => [concept.iri, concept.id])); + const edges: OntologyEdge[] = []; + concepts.forEach((concept) => { + const relations = [ + ...(concept.superClasses ?? []).map((iri) => ({ + iri, + type: 'subClassOf', + })), + ...(concept.conceptMappings ?? []).map((mapping) => ({ + iri: mapping.conceptIri, + type: mapping.mappingType, + })), + ]; + relations.forEach(({ iri, type }) => { + const target = byIri.get(iri); + if (target) { + edges.push({ + id: `${concept.id}:${type}:${target}`, + from: concept.id, + to: target, + label: type, + relationType: type, + edgeKind: 'ontology', + }); + } + }); + }); + + return { + nodes: concepts.map((concept) => ({ + id: concept.id, + label: getEntityName(concept), + type: 'ontologyConcept', + description: concept.description, + fullyQualifiedName: concept.fullyQualifiedName, + group: getEntityName(ontology), + glossaryId: ontology.id, + entityRef: { + id: concept.id, + type: 'ontologyConcept', + fullyQualifiedName: concept.fullyQualifiedName, + }, + })), + edges, + }; +};