From 180a9b0c55ddf166105d8c57eb99087285d12977 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 16 Oct 2026 08:20:11 -0700 Subject: [PATCH 01/10] Add native ontology entities and governed releases --- .github/java-tests/impact-map.json | 1 + .../native/2.1.0/mysql/schemaChanges.sql | 206 ++++ .../native/2.1.0/postgres/schemaChanges.sql | 153 +++ ...nitions-have-independent-entity-storage.md | 184 +++ ...ogy-releases-seal-canonical-sql-members.md | 89 ++ ...eviews-bind-workflow-tasks-to-revisions.md | 79 ++ ...-holds-only-the-active-ontology-release.md | 51 + docs/generated/entity-index.md | 6 +- docs/index.md | 8 +- ...26-10-14-organizational-ontology-design.md | 231 ++++ .../tests/unit/models/test_ontology_iris.py | 236 ++++ .../it/tests/AnnouncementResourceIT.java | 16 +- .../it/tests/AutoCloseIncidentIT.java | 53 +- .../it/tests/BaseOntologyApprovalIT.java | 107 ++ .../openmetadata/it/tests/BaseOntologyIT.java | 202 ++++ .../IndependentOntologyActivationIT.java | 188 +++ .../tests/IndependentOntologyApprovalIT.java | 310 +++++ .../it/tests/IndependentOntologyAxiomIT.java | 298 +++++ .../tests/IndependentOntologyHierarchyIT.java | 104 ++ .../tests/IndependentOntologyReferenceIT.java | 232 ++++ .../IndependentOntologyReleaseDiffIT.java | 218 ++++ .../IndependentOntologyReleaseExportIT.java | 315 +++++ .../tests/IndependentOntologyReleaseIT.java | 462 +++++++ .../tests/IndependentOntologyResourceIT.java | 153 +++ .../tests/IndependentOntologyRetentionIT.java | 171 +++ .../IndependentOntologyReviewRecoveryIT.java | 307 +++++ .../it/tests/OntologyAxiomResourceIT.java | 58 +- .../it/tests/OntologyConceptResourceIT.java | 111 ++ .../it/tests/OntologyPropertyResourceIT.java | 302 +++++ .../it/tests/RdfBlueGreenRebuildIT.java | 4 +- .../it/tests/RdfFusekiAuthorizationIT.java | 23 + .../it/tests/RdfLiveWriteRecoveryIT.java | 38 + .../it/tests/RdfOntologyActivationIT.java | 203 ++++ .../openmetadata/it/tests/TeamResourceIT.java | 16 +- .../it/tests/alerts/BulkChangeEventIT.java | 23 +- .../alerts/EventSubscriptionResourceIT.java | 11 +- .../sdk/client/OpenMetadataClient.java | 28 + .../sdk/services/EntityServiceBase.java | 4 +- .../ontology/OntologyConceptService.java | 37 + .../ontology/OntologyPropertyService.java | 26 + .../ontology/OntologyReleaseService.java | 46 + .../services/ontology/OntologyService.java | 46 + .../java/org/openmetadata/service/Entity.java | 4 + .../service/OpenMetadataApplication.java | 4 + .../service/ResourceRegistry.java | 1 + .../service/apps/bundles/rdf/RdfIndexApp.java | 12 +- .../CatalogGenericExceptionMapper.java | 7 +- .../OntologyValidationException.java | 30 + .../exception/TaskStateConflictException.java | 9 +- .../workflows/OntologyApprovalWorkflow.java | 228 ++++ .../workflows/OntologyReviewReconciler.java | 231 ++++ .../workflows/OntologyWorkflowDecision.java | 43 + .../governance/workflows/WorkflowHandler.java | 148 ++- .../workflows/flowable/MainWorkflow.java | 3 +- .../service/jdbi3/CoreRelationshipDAOs.java | 5 + .../service/jdbi3/EntityRepository.java | 114 +- .../service/jdbi3/ListFilter.java | 22 +- .../jdbi3/OntologyAxiomRepository.java | 145 +-- .../jdbi3/OntologyConceptRepository.java | 163 +++ .../jdbi3/OntologyEntityRepository.java | 221 ++++ .../jdbi3/OntologyPropertyRepository.java | 131 ++ .../service/jdbi3/OntologyReferenceDAO.java | 131 ++ .../service/jdbi3/OntologyReferenceIndex.java | 83 ++ .../service/jdbi3/OntologyReleaseDAO.java | 190 +++ .../jdbi3/OntologyReleaseRepository.java | 410 +++++++ .../service/jdbi3/OntologyRepository.java | 89 ++ .../service/jdbi3/OntologyStateDAO.java | 79 ++ .../service/jdbi3/OntologyWorkingModel.java | 224 ++++ .../service/jdbi3/OntologyWorkingState.java | 145 +++ .../service/jdbi3/RdfInfraDAOs.java | 131 +- .../service/jdbi3/TaskRepository.java | 36 +- .../OntologyChangeApplicationService.java | 10 +- .../OntologyChangeOperationExecutor.java | 29 +- .../ontology/OntologyChangePreflight.java | 24 +- .../ontology/OntologyChangeSetValidator.java | 22 +- .../ontology/OntologyClassHierarchy.java | 334 +++++ .../ontology/OntologyExpressionShape.java | 49 + .../service/ontology/OntologyIriMinter.java | 5 + .../service/ontology/OntologyJson.java | 49 + .../ontology/OntologyLayerValidator.java | 8 +- .../ontology/OntologyPropertyHierarchy.java | 96 ++ .../service/ontology/OntologyReferences.java | 148 +++ .../ontology/OntologyReleaseActivation.java | 112 ++ .../ontology/OntologyReleaseCapture.java | 197 +++ .../ontology/OntologyReleaseComparison.java | 136 +++ .../ontology/OntologyReleaseManifest.java | 156 +++ .../ontology/OntologyReleaseReader.java | 102 ++ .../ontology/OntologyReleaseStructure.java | 21 + .../service/ontology/OntologyReview.java | 95 ++ .../ontology/OntologyStructureValidator.java | 308 +++++ .../service/ontology/OwlDatatypeSupport.java | 78 ++ .../service/ontology/OwlProfileGuard.java | 256 ++-- .../rdf/AssertedGraphTrackingRdfStorage.java | 9 + .../service/rdf/GlossaryOntologyExporter.java | 12 +- .../rdf/GlossaryOntologyModelBuilder.java | 15 +- .../service/rdf/OntologyActiveGraph.java | 87 ++ .../rdf/OntologyExpressionRdfWriter.java | 30 +- .../rdf/OntologyPropertyRdfWriter.java | 136 +++ .../service/rdf/OntologyRdfVocabulary.java | 41 + .../service/rdf/OntologyReleaseExporter.java | 30 + .../rdf/OntologyReleaseProjection.java | 195 +++ .../service/rdf/OwlEntityDeclarations.java | 26 + .../service/rdf/RdfExcludedEntities.java | 17 +- .../service/rdf/RdfLiveWrite.java | 8 + .../service/rdf/RdfLiveWriteStore.java | 15 +- .../service/rdf/RdfProjectionLoaders.java | 8 +- .../service/rdf/RdfRepository.java | 8 + .../openmetadata/service/rdf/RdfUpdater.java | 12 + .../rdf/RelationshipTypeRdfWriter.java | 16 +- .../service/rdf/rebuild/RdfMutation.java | 12 + .../rdf/rebuild/RebuildingRdfStorage.java | 7 + .../rdf/storage/ForwardingRdfStorage.java | 5 + .../InferenceInvalidatingRdfStorage.java | 5 + .../rdf/storage/JenaFusekiStorage.java | 15 + .../rdf/storage/RdfStorageInterface.java | 21 + .../ontology/OntologyAxiomMapper.java | 5 +- .../ontology/OntologyAxiomResource.java | 18 +- .../ontology/OntologyChangeSetResource.java | 2 - .../ontology/OntologyConceptMapper.java | 23 + .../ontology/OntologyConceptResource.java | 213 ++++ .../resources/ontology/OntologyMapper.java | 18 + .../ontology/OntologyPropertyMapper.java | 30 + .../ontology/OntologyPropertyResource.java | 217 ++++ .../ontology/OntologyReleaseResource.java | 222 ++++ .../resources/ontology/OntologyResource.java | 330 +++++ .../ontology/OntologyResourceAccess.java | 36 + .../service/resources/tasks/TaskResource.java | 13 + .../service/security/DefaultAuthorizer.java | 6 +- .../service/tasks/TaskWorkflowHandler.java | 60 +- .../tasks/TaskWorkflowLifecycleResolver.java | 5 +- .../CatalogGenericExceptionMapperTest.java | 30 + .../OntologyApprovalWorkflowTest.java | 106 ++ .../OntologyWorkflowDecisionTest.java | 84 ++ .../jdbi3/EntityRepositoryBulkEventTest.java | 72 ++ .../jdbi3/EntityRepositoryRestoreTest.java | 1 - .../jdbi3/OntologyActiveGraphStorageTest.java | 124 ++ .../service/jdbi3/OntologyCaptureFixture.java | 128 ++ .../service/jdbi3/OntologyEntityDAOTest.java | 1076 +++++++++++++++++ .../jdbi3/OntologyHierarchyStorageTest.java | 289 +++++ .../service/jdbi3/OntologyListFilterTest.java | 33 + .../jdbi3/OntologyReferenceDAOTest.java | 116 ++ .../jdbi3/OntologyReleaseActivationTest.java | 115 ++ .../jdbi3/OntologyReleaseCaptureTest.java | 292 +++++ .../OntologyReleaseComparisonStorageTest.java | 93 ++ .../service/jdbi3/OntologyReleaseDAOTest.java | 313 +++++ .../service/jdbi3/OntologyTestDatabase.java | 184 +++ .../jdbi3/OntologyWorkingStateTest.java | 183 +++ .../v210/FlowableCharsetMigrationTest.java | 6 +- .../OntologyChangeOperationExecutorTest.java | 119 +- .../ontology/OntologyDefinitionTest.java | 162 +++ .../ontology/OntologyIriMinterTest.java | 15 + .../ontology/OntologyLayerValidatorTest.java | 15 + .../ontology/OntologyReferencesTest.java | 178 +++ .../OntologyReleaseComparisonTest.java | 165 +++ .../ontology/OntologyReleaseManifestTest.java | 153 +++ .../service/ontology/OntologyReviewTest.java | 163 +++ .../OntologyStructureValidatorTest.java | 448 +++++++ .../service/ontology/OwlProfileGuardTest.java | 306 ++++- .../rdf/OntologyExpressionRdfWriterTest.java | 38 +- .../rdf/OntologyReleaseProjectionTest.java | 606 ++++++++++ .../service/rdf/RdfExcludedEntitiesTest.java | 12 + .../service/rdf/RdfLiveWriteTest.java | 3 +- .../resources/feeds/MessageParserTest.java | 10 + .../resources/rdf/projection-entities.json | 16 +- .../org/openmetadata/schema/EntityLink.g4 | 4 + .../schema/OntologyDefinition.java | 15 + .../schema/OntologyEntityInterface.java | 10 + .../api/data/activateOntologyRelease.json | 18 + .../api/data/captureOntologyRelease.json | 17 + .../json/schema/api/data/createOntology.json | 52 + .../schema/api/data/createOntologyAxiom.json | 21 +- .../api/data/createOntologyConcept.json | 54 + .../api/data/createOntologyProperty.json | 75 ++ .../schema/api/data/ontologyReleaseDiff.json | 91 ++ .../data/ontologyReleaseStructureReport.json | 75 ++ .../api/data/ontologyValidationError.json | 20 + .../json/schema/entity/data/ontology.json | 100 ++ .../schema/entity/data/ontologyAxiom.json | 24 +- .../schema/entity/data/ontologyConcept.json | 118 ++ .../schema/entity/data/ontologyProperty.json | 169 +++ .../schema/entity/data/ontologyRelease.json | 159 +++ .../accessControl/resourceDescriptor.json | 1 + .../json/schema/entity/tasks/task.json | 4 + .../json/schema/type/conceptMapping.json | 6 +- .../schema/type/ontologyConfiguration.json | 6 +- .../json/schema/type/ontologyExpression.json | 43 +- .../json/schema/type/ontologyIri.json | 9 + .../schema/type/ontologyProfileViolation.json | 19 +- .../json/schema/type/ontologyReference.json | 45 + .../schema/type/ontologyReleaseEvent.json | 42 + .../schema/type/ontologyReleaseMember.json | 54 + .../schema/type/ontologyReviewTarget.json | 53 + .../json/schema/type/ontologyState.json | 35 + .../resources/rdf/ontology/openmetadata.ttl | 120 +- .../e2e/Features/OntologyReview.spec.ts | 828 +++++++++++++ .../TaskFeedCardFromTask.component.test.tsx | 77 ++ .../TaskFeedCardFromTask.component.tsx | 36 +- .../AppRouter/AuthenticatedAppRouter.tsx | 7 + .../TaskTab/TaskTabNew.component.test.tsx | 132 ++ .../Task/TaskTab/TaskTabNew.component.tsx | 52 +- .../components/TaskDetailPanel.test.tsx | 60 + .../InboxPage/components/TaskDetailPanel.tsx | 65 +- .../InboxPage/taskResolve.utils.test.ts | 35 + .../InboxPage/taskResolve.utils.ts | 12 +- .../InboxPage/useTaskAboutEntity.ts | 5 + .../OntologyAuthoringWorkspace.test.tsx | 299 +++++ .../OntologyAuthoringWorkspace.tsx | 84 ++ .../OntologyDefinitionList.tsx | 193 +++ .../OntologyAxiomWorkspace.tsx | 41 + .../OntologyConceptEditor.test.tsx | 430 +++++++ .../OntologyConceptEditor.tsx | 146 +++ .../OntologyConceptEditor.utils.ts | 223 ++++ .../OntologyCreateForm.test.tsx | 133 ++ .../OntologyCreateForm/OntologyCreateForm.tsx | 96 ++ .../OntologyCreateForm.utils.ts | 135 +++ .../OntologyGraphView/OntologyGraphView.tsx | 183 +++ .../OntologyPicker/OntologyPicker.tsx | 95 ++ .../OntologyPropertyEditor.test.tsx | 268 ++++ .../OntologyPropertyEditor.tsx | 160 +++ .../OntologyPropertyEditor.utils.ts | 254 ++++ .../OntologyPublicationWorkspace.tsx | 39 + .../OntologyReleaseDetails.tsx | 153 +++ .../OntologyReleasePanel.test.tsx | 444 +++++++ .../OntologyReleasePanel.tsx | 119 ++ .../OntologyReleasePicker.tsx | 104 ++ .../OntologyReviewDefinition.tsx | 45 + .../OntologyReviewPanel.test.tsx | 201 +++ .../OntologyReviewPanel.tsx | 114 ++ .../OntologyReviewRelease.tsx | 181 +++ .../ui/src/enums/permissions.enum.ts | 5 + .../api/data/activateOntologyRelease.ts | 20 + .../api/data/captureOntologyRelease.ts | 18 + .../src/generated/api/data/createOntology.ts | 112 ++ .../generated/api/data/createOntologyAxiom.ts | 50 +- .../api/data/createOntologyChangeSet.ts | 47 +- .../api/data/createOntologyConcept.ts | 148 +++ .../api/data/createOntologyProperty.ts | 133 ++ .../api/data/ontologyDomainDraftResult.ts | 47 +- .../api/data/ontologyMappingSuggestion.ts | 47 +- .../api/data/ontologyMappingSuggestionList.ts | 47 +- .../api/data/ontologyProfileReport.ts | 5 + .../data/ontologyRelationshipSuggestion.ts | 47 +- .../ontologyRelationshipSuggestionList.ts | 47 +- .../generated/api/data/ontologyReleaseDiff.ts | 151 +++ .../data/ontologyReleaseStructureReport.ts | 89 ++ .../api/data/ontologyValidationError.ts | 76 ++ .../api/data/updateOntologyChangeSet.ts | 47 +- .../generated/api/policies/createPolicy.ts | 1 + .../ui/src/generated/entity/data/ontology.ts | 197 +++ .../generated/entity/data/ontologyAxiom.ts | 19 +- .../entity/data/ontologyChangeSet.ts | 47 +- .../generated/entity/data/ontologyConcept.ts | 235 ++++ .../generated/entity/data/ontologyProperty.ts | 237 ++++ .../generated/entity/data/ontologyRelease.ts | 243 ++++ .../accessControl/resourceDescriptor.ts | 1 + .../accessControl/resourcePermission.ts | 1 + .../entity/policies/accessControl/rule.ts | 1 + .../src/generated/entity/policies/policy.ts | 1 + .../ui/src/generated/entity/tasks/task.ts | 32 + .../generated/type/ontologyChangeOperation.ts | 47 +- .../type/ontologyProfileViolation.ts | 5 + .../src/generated/type/ontologyReference.ts | 87 ++ .../generated/type/ontologyReleaseEvent.ts | 168 +++ .../generated/type/ontologyReleaseMember.ts | 37 + .../generated/type/ontologyReviewTarget.ts | 37 + .../ui/src/generated/type/ontologyState.ts | 28 + .../ontology/useOntologyPublication.ts | 142 +++ .../ui/src/locale/languages/ar-sa.json | 20 + .../ui/src/locale/languages/de-de.json | 20 + .../ui/src/locale/languages/en-us.json | 20 + .../ui/src/locale/languages/es-es.json | 20 + .../ui/src/locale/languages/fr-fr.json | 20 + .../ui/src/locale/languages/gl-es.json | 20 + .../ui/src/locale/languages/he-he.json | 20 + .../ui/src/locale/languages/ja-jp.json | 20 + .../ui/src/locale/languages/ko-kr.json | 20 + .../ui/src/locale/languages/mr-in.json | 20 + .../ui/src/locale/languages/nl-nl.json | 20 + .../ui/src/locale/languages/pr-pr.json | 20 + .../ui/src/locale/languages/pt-br.json | 20 + .../ui/src/locale/languages/pt-pt.json | 20 + .../ui/src/locale/languages/ru-ru.json | 20 + .../ui/src/locale/languages/sv-se.json | 20 + .../ui/src/locale/languages/th-th.json | 20 + .../ui/src/locale/languages/tr-tr.json | 20 + .../ui/src/locale/languages/zh-cn.json | 20 + .../ui/src/locale/languages/zh-tw.json | 20 + .../tasks/TaskDetailsPage/TaskDetailsPage.tsx | 37 + .../resources/ui/src/rest/ontologyAPI.test.ts | 141 ++- .../main/resources/ui/src/rest/ontologyAPI.ts | 278 ++++- .../TaskNavigationUtils.knowledgePage.test.ts | 33 +- .../ui/src/utils/TaskNavigationUtils.ts | 3 +- .../main/resources/ui/src/utils/TaskUtils.ts | 29 +- .../ontology/OntologyAuthoring.utils.ts | 19 + .../ontology/OntologyGraph.utils.test.ts | 84 ++ .../ontology/OntologyGraph.utils.ts | 70 ++ 296 files changed, 27887 insertions(+), 1029 deletions(-) create mode 100644 docs/decisions/2026-10-14-ontology-definitions-have-independent-entity-storage.md create mode 100644 docs/decisions/2026-10-14-ontology-releases-seal-canonical-sql-members.md create mode 100644 docs/decisions/2026-10-14-ontology-reviews-bind-workflow-tasks-to-revisions.md create mode 100644 docs/decisions/2026-10-15-fuseki-holds-only-the-active-ontology-release.md create mode 100644 docs/plans/2026-10-14-organizational-ontology-design.md create mode 100644 ingestion/tests/unit/models/test_ontology_iris.py create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseOntologyApprovalIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseOntologyIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyActivationIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyApprovalIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyAxiomIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyHierarchyIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReferenceIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseDiffIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseExportIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyResourceIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyRetentionIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReviewRecoveryIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyConceptResourceIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyPropertyResourceIT.java create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/RdfOntologyActivationIT.java create mode 100644 openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyConceptService.java create mode 100644 openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyPropertyService.java create mode 100644 openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyReleaseService.java create mode 100644 openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/ontology/OntologyService.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/exception/OntologyValidationException.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyApprovalWorkflow.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyReviewReconciler.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyWorkflowDecision.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyConceptRepository.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyEntityRepository.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyPropertyRepository.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReferenceDAO.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReferenceIndex.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReleaseDAO.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReleaseRepository.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyRepository.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyStateDAO.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyWorkingModel.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyWorkingState.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyClassHierarchy.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyExpressionShape.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyJson.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyPropertyHierarchy.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReferences.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseActivation.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseCapture.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseComparison.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseManifest.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseReader.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseStructure.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReview.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyStructureValidator.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OwlDatatypeSupport.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyActiveGraph.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyPropertyRdfWriter.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyRdfVocabulary.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyReleaseExporter.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyReleaseProjection.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyConceptMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyConceptResource.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyPropertyMapper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyPropertyResource.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyReleaseResource.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyResource.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyResourceAccess.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/governance/workflows/OntologyApprovalWorkflowTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/governance/workflows/OntologyWorkflowDecisionTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryBulkEventTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyActiveGraphStorageTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyCaptureFixture.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyEntityDAOTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyHierarchyStorageTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyListFilterTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReferenceDAOTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseActivationTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseCaptureTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseComparisonStorageTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseDAOTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyTestDatabase.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyWorkingStateTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyDefinitionTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReferencesTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReleaseComparisonTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReleaseManifestTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReviewTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyStructureValidatorTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/rdf/OntologyReleaseProjectionTest.java create mode 100644 openmetadata-spec/src/main/java/org/openmetadata/schema/OntologyDefinition.java create mode 100644 openmetadata-spec/src/main/java/org/openmetadata/schema/OntologyEntityInterface.java create mode 100644 openmetadata-spec/src/main/resources/json/schema/api/data/activateOntologyRelease.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/api/data/captureOntologyRelease.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/api/data/createOntology.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyConcept.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyProperty.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/api/data/ontologyReleaseDiff.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/api/data/ontologyReleaseStructureReport.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/api/data/ontologyValidationError.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/entity/data/ontology.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyConcept.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyProperty.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyRelease.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/type/ontologyIri.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/type/ontologyReference.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/type/ontologyReleaseEvent.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/type/ontologyReleaseMember.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/type/ontologyReviewTarget.json create mode 100644 openmetadata-spec/src/main/resources/json/schema/type/ontologyState.json create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyReview.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAuthoringWorkspace/OntologyAuthoringWorkspace.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAuthoringWorkspace/OntologyAuthoringWorkspace.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAuthoringWorkspace/OntologyDefinitionList.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomWorkspace/OntologyAxiomWorkspace.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyConceptEditor/OntologyConceptEditor.utils.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.utils.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyGraphView/OntologyGraphView.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPicker/OntologyPicker.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPropertyEditor/OntologyPropertyEditor.utils.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyPublicationWorkspace/OntologyPublicationWorkspace.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleaseDetails.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleasePanel.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleasePanel.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePicker/OntologyReleasePicker.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewDefinition.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewPanel.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewPanel.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewRelease.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/api/data/activateOntologyRelease.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/api/data/captureOntologyRelease.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntology.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyConcept.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyProperty.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyReleaseDiff.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyReleaseStructureReport.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyValidationError.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontology.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyConcept.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyProperty.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyRelease.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReference.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReleaseEvent.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReleaseMember.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReviewTarget.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyState.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/hooks/governance/ontology/useOntologyPublication.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/entity/tasks/TaskDetailsPage/TaskDetailsPage.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/governance/ontology/OntologyAuthoring.utils.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/governance/ontology/OntologyGraph.utils.test.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/governance/ontology/OntologyGraph.utils.ts 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/bootstrap/sql/migrations/native/2.1.0/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/2.1.0/mysql/schemaChanges.sql index c0ee9f1d8f2c..9276666528f5 100644 --- a/bootstrap/sql/migrations/native/2.1.0/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.1.0/mysql/schemaChanges.sql @@ -878,3 +878,209 @@ DEALLOCATE PREPARE rdf_projection_health_watermark_stmt; -- 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; + +-- 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_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_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_CONTAINS(json, '{"operationType":"UPSERT_AXIOM"}', '$.operations') OR JSON_CONTAINS(json, '{"operationType":"DELETE_AXIOM"}', '$.operations')); +DELETE FROM ontology_change_set_entity WHERE JSON_CONTAINS(json, '{"operationType":"UPSERT_AXIOM"}', '$.operations') OR JSON_CONTAINS(json, '{"operationType":"DELETE_AXIOM"}', '$.operations'); 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..67806af4a1d2 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->'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->'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->'operations' @> '[{"operationType":"UPSERT_AXIOM"}]'::jsonb OR json->'operations' @> '[{"operationType":"DELETE_AXIOM"}]'::jsonb); +DELETE FROM ontology_change_set_entity WHERE 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..5a2f1742842d --- /dev/null +++ b/docs/decisions/2026-10-14-ontology-definitions-have-independent-entity-storage.md @@ -0,0 +1,184 @@ +# 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 +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 and nonempty import requests are rejected. + +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. + +## Follow-up: metadata usage search + +Keep `tag_usage` authoritative for metadata annotations. Project the distinct UUIDs from native +ontology labels into the keyword field `ontologyConceptIds` in asset search documents. Include +annotated child fields in their parent asset's document and retain separate column documents when +column indexing is enabled. Never derive this field from another label source, even when it carries +the same FQN or metadata keys. Reindexing and live tag-update scripts produce the same projection. + +Reverse asset lists and counts use the existing search endpoint and its access controls. Query by +concept UUID rather than FQN, preserving unresolved assignments after working-definition deletion +or name reuse. The search projection is eventually consistent; SQL remains the assignment record. +Published concept pages authorize the sealed definition before showing associated assets. Search +failures have an explicit retry state and do not imply zero associations. Existing indexes require +the normal mapping update and reindex to populate the new field. `ParseTagsTest`, `TaggableIndexTest`, +`CustomIndexTest`, and `IndependentOntologyLabelsIT` guard this projection. + +Define the shared keyword field in `IndexMapping.readMapping`, used by index creation, updates, +rebuilds, mapping lookup and version fingerprints. Language-specific JSON files retain their existing +fields and analyzers; they do not repeat the ontology field. This field is independent of optional +search hardening settings. + +## Follow-up: metadata CSV annotations + +Append an optional `ontologyAnnotations` column to metadata CSV formats. Its value is a JSON array +of existing `TagLabel` objects carrying source `Ontology`, stable ontology/concept UUIDs, assignment +state, label type and provenance. Export omits derived display fields and the computed `resolved` +flag. Classification and glossary columns keep their existing meanings and positions. + +An omitted column preserves each target's current ontology annotations; an explicitly empty cell +removes them. Import validates supplied identities through `OntologyLabelResolver`, including in +dry runs, and persists through the existing repository/tag-usage mechanisms. The same codec serves +entity, nested-column, bulk-column and search-result exports. The bulk edit grid displays concept +names and suggestion state, retains the serialized annotations, and keeps this structured column +read-only. CSV files can replace the column explicitly; malformed or forged assignments fail the row. + +Define the annotation header once in `OntologyAnnotationCsv`, applied by the existing documentation +loader and CSV constructor to formats with a tag column. This also covers headers declared in Java. +Row builders use `CsvUtil.addOntologyAnnotations`, alongside the existing classification and glossary +helpers; the generic two-argument record writer stays unchanged. + +## 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. + +In the metadata follow-up, annotations reuse the existing `tag_usage` store and shared tag writers. Batch writes +resolve identities and approved/active content together through existing chunked DAO queries. +Cached assignments refresh their meaning from the active release on read. A column datatype +change carries its existing annotation identity, assignment state and provenance forward, even +when its concept is no longer active; it does not authorize a new assignment to an unapproved concept. 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..13399ba5dcdc --- /dev/null +++ b/docs/decisions/2026-10-14-ontology-reviews-bind-workflow-tasks-to-revisions.md @@ -0,0 +1,79 @@ +# Ontology releases use the existing approval workflow + +- **Status:** Accepted +- **Revisions:** v1 2026-10-14 (initial); v11 2026-10-15 (release-only decisions and mandatory review content) +- **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, content revision/digest, author, owning ontology UUID and workflow version/ +digest in the task's immutable `ontologyReview` field. 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-v2`. Exclude owners, reviewers, operational status/audit fields +and derived hierarchy fields. 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. Preserve the submitted workflow fingerprint as evidence, while executing +the actual runtime approval step rather than interpreting a new workflow definition mid-process. + +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 recovery closes the invalid review; 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 cancel the pending review. 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..6d77b643e34c --- /dev/null +++ b/docs/decisions/2026-10-15-fuseki-holds-only-the-active-ontology-release.md @@ -0,0 +1,51 @@ +# Fuseki holds only the active ontology release + +- **Status:** Accepted +- **Revisions:** v1 2026-10-15 (initial) +- **Deciders:** Harsha +- **Guard:** `IndependentOntologyActivationIT`, `OntologyReleaseActivationTest`, `OntologyActiveGraphStorageTest`, `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. 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:-- patchEntity(created.getId().toString(), created)); InvalidRequestException rejected = - assertInstanceOf(InvalidRequestException.class, error.getCause()); + assertThrows( + InvalidRequestException.class, () -> patchEntity(created.getId().toString(), created)); assertTrue(rejected.getMessage().contains("64")); assertEquals("Release", getEntity(created.getId().toString()).getCustomTypeName()); } diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoCloseIncidentIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoCloseIncidentIT.java index 578ea1411e76..b52aa78d18fe 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoCloseIncidentIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoCloseIncidentIT.java @@ -15,6 +15,7 @@ import static org.awaitility.Awaitility.await; 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.assertTrue; @@ -228,15 +229,20 @@ void autoCloseDisabled_testPasses_incidentStaysOpen(TestNamespace ns) { // Verify incident is NOT resolved — no Resolved TCRS record appears await() .during(Duration.ofSeconds(10)) - .atMost(Duration.ofSeconds(15)) + .atMost(PIPELINE_TIMEOUT) .pollInterval(Duration.ofSeconds(2)) - .until( - () -> - listTcrsForStateId(client, stateId).stream() - .noneMatch( - r -> - r.getTestCaseResolutionStatusType() - == TestCaseResolutionStatusTypes.Resolved)); + .untilAsserted( + () -> { + final var statuses = listTcrsForStateId(client, stateId); + assertFalse(statuses.isEmpty(), "The incident history must remain available"); + assertTrue( + statuses.stream() + .noneMatch( + status -> + status.getTestCaseResolutionStatusType() + == TestCaseResolutionStatusTypes.Resolved), + () -> "Auto-close is disabled: " + statuses); + }); // Workflow should still be running String workflowInstanceId = taskRef.get().getWorkflowInstanceId().toString(); @@ -307,28 +313,19 @@ private Task findIncidentTaskForTestCase(OpenMetadataClient client, TestCase tes return null; } - @SuppressWarnings("unchecked") private List listTcrsForStateId( OpenMetadataClient client, UUID stateId) { - try { - String response = - client - .getHttpClient() - .executeForString( - HttpMethod.GET, - "/v1/dataQuality/testCases/testCaseIncidentStatus/stateId/" + stateId, - null, - RequestOptions.builder().build()); - - Map result = JsonUtils.readValue(response, new TypeReference<>() {}); - List data = (List) result.get("data"); - if (data == null) return List.of(); - return data.stream() - .map(d -> JsonUtils.convertValue(d, TestCaseResolutionStatus.class)) - .toList(); - } catch (Exception e) { - return List.of(); - } + final String response = + client + .getHttpClient() + .executeForString( + HttpMethod.GET, + "/v1/dataQuality/testCases/testCaseIncidentStatus/stateId/" + stateId, + null, + RequestOptions.builder().build()); + return JsonUtils.readValue( + response, new TypeReference>() {}) + .getData(); } @SuppressWarnings("unchecked") diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseOntologyApprovalIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseOntologyApprovalIT.java new file mode 100644 index 000000000000..d30131886653 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseOntologyApprovalIT.java @@ -0,0 +1,107 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.openmetadata.it.bootstrap.SharedEntities; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.schema.api.data.CaptureOntologyRelease; +import org.openmetadata.schema.api.data.CreateOntology; +import org.openmetadata.schema.api.data.CreateOntologyConcept; +import org.openmetadata.schema.api.tasks.CreateTask; +import org.openmetadata.schema.api.tasks.ResolveTask; +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.OntologyConceptType; +import org.openmetadata.schema.type.TaskCategory; +import org.openmetadata.schema.type.TaskEntityType; +import org.openmetadata.schema.type.TaskResolutionType; + +abstract class BaseOntologyApprovalIT { + @AfterEach + void cleanup(TestNamespace ns) { + BaseOntologyIT.deleteRoots(ns); + } + + protected static Ontology createOntology(TestNamespace ns) { + return createOntology(ns, "reviewedOntology"); + } + + protected static OntologyConcept declareClass(Ontology ontology, String name, URI iri) { + return SdkClients.adminClient() + .ontologyConcepts() + .create( + new CreateOntologyConcept() + .withOntology(ontology.getFullyQualifiedName()) + .withName(name) + .withIri(iri) + .withConceptType(OntologyConceptType.OWL_CLASS)); + } + + protected static OntologyRelease capture(Ontology ontology) { + final var service = SdkClients.adminClient().ontologies(); + return service.capture( + ontology.getId(), + new CaptureOntologyRelease() + .withExpectedWorkingRevision(service.state(ontology.getId()).getWorkingRevision())); + } + + protected static Ontology createOntology(TestNamespace ns, String name) { + Ontology ontology = + SdkClients.adminClient().ontologies().create(request(ns).withName(ns.prefix(name))); + ns.trackRoot("ontology", ontology); + return ontology; + } + + protected static CreateOntology request(TestNamespace ns) { + return new CreateOntology() + .withName(ns.prefix("reviewedOntology")) + .withIri(URI.create("https://example.org/model/" + ns.shortPrefix())) + .withNamespace(URI.create("https://example.org/model/")) + .withReviewers(List.of(SharedEntities.get().USER1.getEntityReference())); + } + + protected static Task submit(TestNamespace ns, String type, String fqn) { + Task task = SdkClients.adminClient().tasks().create(reviewRequest(ns, type, fqn)); + ns.trackRoot("task", task); + Awaitility.await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + 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..708a32c998e7 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseOntologyIT.java @@ -0,0 +1,202 @@ +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()); + 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/IndependentOntologyActivationIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyActivationIT.java new file mode 100644 index 000000000000..34779ea4bb7a --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyActivationIT.java @@ -0,0 +1,188 @@ +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.entity.data.Ontology; +import org.openmetadata.schema.entity.data.OntologyRelease; +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 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..e841d961a1d1 --- /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.getReviewRevision(), task.getOntologyReview().getReviewRevision()); + 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.getReviewRevision(), current(ontology).getReviewRevision()); + 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.getReviewRevision(), current(ontology).getReviewRevision()); + 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/IndependentOntologyAxiomIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyAxiomIT.java new file mode 100644 index 000000000000..72d3df9344e1 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyAxiomIT.java @@ -0,0 +1,298 @@ +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.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.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.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 axiomsBelongToOntologiesAndInheritTheirReviewers(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(), "reviewers"); + assertEquals(SharedEntities.get().USER1.getId(), stored.getReviewers().getFirst().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 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..c3ac6766420f --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyHierarchyIT.java @@ -0,0 +1,104 @@ +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.OntologyConceptType; +import org.openmetadata.schema.type.OntologyExpression; +import org.openmetadata.schema.type.OntologyExpressionKind; +import org.openmetadata.schema.type.RelationProvenance; +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.update( + party.getId(), party.withConceptType(OntologyConceptType.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..5b1434ce3764 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseDiffIT.java @@ -0,0 +1,218 @@ +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.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)); + 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()); + } + + @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..94a2f99016b7 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseExportIT.java @@ -0,0 +1,315 @@ +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.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() + .update( + declaration.getId(), + declaration.withKind(OntologyPropertyKind.DATA).withRange(List.of()))) + .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..14ece924020f --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReleaseIT.java @@ -0,0 +1,462 @@ +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("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..5eb628fd1a17 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyResourceIT.java @@ -0,0 +1,153 @@ +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 importsAreExplicitlyUnsupported(TestNamespace ns) { + Ontology imported = createEntity(createRequest(ns.prefix("imported"), ns)); + ns.trackRoot("ontology", imported); + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, + () -> + createEntity( + createMinimalRequest(ns) + .withImports(List.of(imported.getEntityReference())))) + .getStatusCode()); + Ontology root = createEntity(createMinimalRequest(ns)); + ns.trackRoot("ontology", root); + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, + () -> + patchEntity( + root.getId().toString(), + root.withImports(List.of(imported.getEntityReference())))) + .getStatusCode()); + assertEquals(List.of(), getEntity(root.getId().toString()).getImports()); + } + + @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..525ab0d50961 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyRetentionIT.java @@ -0,0 +1,171 @@ +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,reviewers", "deleted"); + assertTrue(archived.getDeleted()); + assertFalse(archived.getReviewers().isEmpty()); + 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.DRAFT, client.ontologyReleases().get(release.getId()).getEntityStatus()); + assertEquals( + TaskEntityStatus.Cancelled, client.tasks().get(task.getId().toString()).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..d56fa1385f7a --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyReviewRecoveryIT.java @@ -0,0 +1,307 @@ +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 archivingTheOntologyCancelsItsReleaseReview(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.Cancelled, + 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/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..f7aa25b43d9a --- /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.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 { + @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")))); + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, + () -> entityService().update(created.getId().toString(), created)) + .getStatusCode()); + created.setEnumValues( + List.of(new OntologyLiteral().withValue("Gold"), new OntologyLiteral().withValue("Gold"))); + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, + () -> entityService().update(created.getId().toString(), created)) + .getStatusCode()); + 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)); + assertEquals( + 400, + assertThrows( + OpenMetadataException.class, + () -> entityService().update(property.getId().toString(), property)) + .getStatusCode()); + 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..db23e2d685cf 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 @@ -106,6 +106,29 @@ void writerCanUploadThroughGraphStore(final String dataset) throws Exception { assertTrue(contains(dataset, subject)); } + @ParameterizedTest + @ValueSource(strings = {"openmetadata", "openmetadata_a", "openmetadata_b"}) + void graphStorePutReplacesAtomicallyWithWriterCredentials(final String dataset) throws Exception { + final String first = "urn:review:" + UUID.randomUUID(); + final String second = "urn:review:" + UUID.randomUUID(); + assertEquals(2, replace(dataset, "<" + first + "> .").statusCode() / 100); + assertEquals(2, replace(dataset, "<" + second + "> .").statusCode() / 100); + assertFalse(contains(dataset, first)); + assertTrue(contains(dataset, second)); + final var invalid = replace(dataset, " ."); + assertTrue(invalid.statusCode() >= 400, invalid.body()); + assertTrue(contains(dataset, second)); + } + + private static HttpResponse replace(final String dataset, 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 @ValueSource(strings = {"openmetadata", "openmetadata_a", "openmetadata_b"}) void anonymousUploadCannotMutateAnyDataset(final String dataset) throws Exception { 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/TeamResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TeamResourceIT.java index eb8f6c3d44a3..48099c015667 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TeamResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TeamResourceIT.java @@ -56,6 +56,7 @@ import org.openmetadata.schema.type.csv.CsvImportResult; import org.openmetadata.sdk.client.OpenMetadataClient; import org.openmetadata.sdk.exceptions.ForbiddenException; +import org.openmetadata.sdk.exceptions.InvalidRequestException; import org.openmetadata.sdk.models.ListParams; import org.openmetadata.sdk.models.ListResponse; import org.openmetadata.sdk.network.HttpMethod; @@ -73,8 +74,6 @@ public class TeamResourceIT extends BaseEntityIT { private static final String DIRECT_USER_ASSIGNMENT_ERROR = "Team is of type Department. Direct users can only be assigned to teams of type Group."; - private static final String UPDATE_DIRECT_USER_ASSIGNMENT_ERROR = - "Failed to update entity: " + DIRECT_USER_ASSIGNMENT_ERROR; { supportsEntityStatus = false; @@ -316,10 +315,11 @@ void test_departmentRejectsDirectUsersOnPatch(TestNamespace ns) { Team update = SdkClients.adminClient().teams().get(department.getId().toString(), "users"); update.setUsers(List.of(user.getEntityReference())); - Exception exception = - assertThrows(Exception.class, () -> patchEntity(update.getId().toString(), update)); + InvalidRequestException exception = + assertThrows( + InvalidRequestException.class, () -> patchEntity(update.getId().toString(), update)); - assertEquals(UPDATE_DIRECT_USER_ASSIGNMENT_ERROR, exception.getMessage()); + assertEquals(DIRECT_USER_ASSIGNMENT_ERROR, exception.getMessage()); } @Test @@ -389,12 +389,12 @@ void test_userCannotBeUpdatedToDepartment(TestNamespace ns) { User update = SdkClients.adminClient().users().get(user.getId().toString(), "teams"); update.setTeams(List.of(department.getEntityReference())); - Exception exception = + InvalidRequestException exception = assertThrows( - Exception.class, + InvalidRequestException.class, () -> SdkClients.adminClient().users().update(user.getId().toString(), update)); - assertEquals(UPDATE_DIRECT_USER_ASSIGNMENT_ERROR, exception.getMessage()); + assertEquals(DIRECT_USER_ASSIGNMENT_ERROR, exception.getMessage()); } @Test diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/BulkChangeEventIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/BulkChangeEventIT.java index f0f82c788361..952b36e497d9 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/BulkChangeEventIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/BulkChangeEventIT.java @@ -172,8 +172,10 @@ void test_bulkUpdate_matchesSingleEntityPut(TestNamespace ns) throws Exception { * deleted} field change — so this now emits ENTITY_UPDATED where before the fix it emitted * nothing. New behaviour, matching the single-PUT path, pinned here so it cannot regress silently. */ - @Test - void test_bulkUndeleteViaBulkPut_emitsEvent(TestNamespace ns) throws Exception { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void test_bulkUndeleteViaBulkPut_emitsEvent(boolean addColumn, TestNamespace ns) + throws Exception { String schemaFqn = setupSchema(ns); CreateTable create = table(ns, schemaFqn, "evt_undel", null); BulkApi.upsert("tables", List.of(create), false, BulkApi.botToken()); @@ -183,13 +185,28 @@ void test_bulkUndeleteViaBulkPut_emitsEvent(TestNamespace ns) throws Exception { softDelete(created.getId().toString()); long since = System.currentTimeMillis(); - BulkApi.upsert("tables", List.of(create), false, BulkApi.botToken()); + BulkApi.upsert( + "tables", + List.of(addColumn ? withExtraColumn(create, "restored_col") : create), + false, + BulkApi.botToken()); ChangeEvent event = awaitUpdatedEvent(created.getId(), since); assertNotNull(event.getChangeDescription(), "un-delete event must carry a change description"); assertTrue( allFieldNames(event).contains("deleted"), "bulk un-delete must record the 'deleted' field change, got: " + allFieldNames(event)); + final var deletedChange = + event.getChangeDescription().getFieldsUpdated().stream() + .filter(change -> "deleted".equals(change.getName())) + .findFirst() + .orElseThrow(); + assertEquals(true, deletedChange.getOldValue()); + assertEquals(false, deletedChange.getNewValue()); + assertEquals(false, getTable(fqn).getDeleted()); + if (addColumn) { + assertTrue(allFieldNames(event).contains("columns")); + } } /** diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/EventSubscriptionResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/EventSubscriptionResourceIT.java index 7a1a91360f06..990f427896ed 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/EventSubscriptionResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/EventSubscriptionResourceIT.java @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -162,15 +161,12 @@ void test_patchWebhookEndpointToLoopbackAddress_400(TestNamespace ns) { Webhook webhook = JsonUtils.convertValue(destination.getConfig(), Webhook.class); destination.withConfig(webhook.withEndpoint(LOOPBACK_ENDPOINT)); - // The SDK wraps a patch failure, so the server's status is on the cause. - OpenMetadataException rejected = + InvalidRequestException rejected = assertThrows( - OpenMetadataException.class, + InvalidRequestException.class, () -> patchEntity(subscription.getId().toString(), subscription), "Patching an endpoint to a loopback address should be rejected"); - InvalidRequestException cause = - assertInstanceOf(InvalidRequestException.class, rejected.getCause()); - assertEquals(400, cause.getStatusCode()); + assertEquals(400, rejected.getStatusCode()); } private List getWebhookDestination(TestNamespace ns) { @@ -1003,7 +999,6 @@ private void toggleEnabled(String subscriptionId, boolean enabled) { } } - /** The SDK wraps the transport failure, so the real status sits on a cause rather than the top. */ private static int statusCodeOf(Throwable failure) { for (Throwable cause = failure; cause != null; cause = cause.getCause()) { if (cause instanceof OpenMetadataException reported && reported.getStatusCode() > 0) { 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/EntityServiceBase.java b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/EntityServiceBase.java index eaa518731b44..06824c9498f5 100644 --- a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/EntityServiceBase.java +++ b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/EntityServiceBase.java @@ -289,7 +289,7 @@ public T update(String id, T entity, String etag) throws OpenMetadataException { original = fetchEntity(id, null); } - originalNode = objectMapper.readTree(objectMapper.writeValueAsString(original)); + originalNode = objectMapper.valueToTree(original); } // Remove computed/read-only fields that cannot be patched @@ -306,6 +306,8 @@ public T update(String id, T entity, String etag) throws OpenMetadataException { return httpClient.execute( HttpMethod.PATCH, basePath + "/" + id, patch, getEntityClass(), options); + } catch (OpenMetadataException e) { + throw e; } catch (Exception e) { throw new OpenMetadataException("Failed to update entity: " + e.getMessage(), e); } 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..ff99745ca519 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java @@ -124,6 +124,7 @@ import org.openmetadata.service.exception.JsonMappingExceptionMapper; import org.openmetadata.service.exception.OMErrorPageHandler; import org.openmetadata.service.fernet.Fernet; +import org.openmetadata.service.governance.workflows.OntologyReviewReconciler; import org.openmetadata.service.governance.approval.ChangeRequestRecoveryScheduler; import org.openmetadata.service.governance.workflows.WorkflowHandler; import org.openmetadata.service.jdbi3.BulkExecutor; @@ -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/ResourceRegistry.java b/openmetadata-service/src/main/java/org/openmetadata/service/ResourceRegistry.java index ba2b0567e9bf..9850510d4576 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ResourceRegistry.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ResourceRegistry.java @@ -41,6 +41,7 @@ public class ResourceRegistry { MetadataOperation.EDIT_DESCRIPTION, MetadataOperation.EDIT_DISPLAY_NAME, MetadataOperation.EDIT_GLOSSARY_TERMS, + MetadataOperation.EDIT_ONTOLOGY_CONCEPTS, MetadataOperation.EDIT_TIER, MetadataOperation.CREATE_TASK, MetadataOperation.EDIT_TASK)); 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/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..8e576fc7b30d --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyReviewReconciler.java @@ -0,0 +1,231 @@ +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) { + cancelObsoleteReview(tasks, task, 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/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 d9e4a7f3b1c4..c221472c88bf 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; } @@ -1243,6 +1249,7 @@ public final T getForInheritance(UUID id, Fields fields, Include include) { // find() above does not hydrate tags, so this ancestor would contribute only the tags it // inherits in turn and never its own -- see fetchInheritableRelationships. fetchAndSetTags(List.of(entity), fields); + fetchAndSetReviewers(List.of(entity), fields); if (!requiresParentForInheritance(entity, fields)) { return entity; } @@ -1309,6 +1316,7 @@ public void fetchInheritableRelationships(List entities, Fields fields) { // tags live in tag_usage and so arrive null. Leaving them that way makes each hop contribute // nothing but what it inherited in turn, and a tag set on a service never reaches a table. fetchAndSetTags(entities, fields); + fetchAndSetReviewers(entities, fields); } @SuppressWarnings("unchecked") @@ -4399,9 +4407,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 +4421,7 @@ public List createManyEntitiesForImport(List entities, String impersonated // the // handle is held). flushInOneTransaction( + entities, () -> { storeEntities(entities); storeExtensions(entities); @@ -4440,9 +4447,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++) { @@ -4699,9 +4704,10 @@ private PutResponse putAndStore( boolean requireCurrentVersion, Set patchedFields) { // If the entity state is soft-deleted, recursively undelete the entity and it's children + final T beforeRestore = original; if (Boolean.TRUE.equals(original.getDeleted())) { try (var ignored = phase("putRestoreEntity")) { - restoreEntity(updated.getUpdatedBy(), original.getId()); + original = restoreForUpdate(original, updated); } } @@ -4721,6 +4727,7 @@ private PutResponse putAndStore( entityUpdater.update(); } } + entityUpdater.includeRestoration(beforeRestore); EventType change = entityUpdater.getChangeType(); try (var ignored = phase("putSetInheritedFields")) { setInheritedFields(updated, new Fields(allowedFields)); @@ -4757,9 +4764,10 @@ public final PutResponse updateForImport( private PutResponse putForImportAndStore(UriInfo uriInfo, T original, T updated) { // If the entity state is soft-deleted, recursively undelete the entity and it's children + final T beforeRestore = original; if (Boolean.TRUE.equals(original.getDeleted())) { try (var ignored = phase("putRestoreEntityImport")) { - restoreEntity(updated.getUpdatedBy(), original.getId()); + original = restoreForUpdate(original, updated); } } @@ -4768,6 +4776,7 @@ private PutResponse putForImportAndStore(UriInfo uriInfo, T original, T updat try (var ignored = phase("putEntityUpdateImport")) { entityUpdater.updateForImport(); } + entityUpdater.includeRestoration(beforeRestore); EventType change = entityUpdater.getChangeType(); try (var ignored = phase("putSetInheritedFieldsImport")) { setInheritedFields(updated, new Fields(allowedFields)); @@ -4778,6 +4787,15 @@ private PutResponse putForImportAndStore(UriInfo uriInfo, T original, T updat return new PutResponse<>(Status.OK, withHref(uriInfo, updated), change); } + private T restoreForUpdate(T original, T updated) { + // Restore writes its own version; the following update must compare against that revision. + PutResponse restored = restoreEntity(updated.getUpdatedBy(), original.getId()); + updated.setDeleted(false); + return restored == null + ? get(null, original.getId(), putFields, NON_DELETED, false) + : restored.getEntity(); + } + /** * Use method with ChangeContext */ @@ -5425,6 +5443,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 +5667,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 +5932,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 +5972,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(); + } + + private 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 +6248,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)); @@ -7574,7 +7613,9 @@ public final PutResponse restoreEntity(String updatedBy, UUID id) { // Repositories are instantiated directly rather than as JDBI SQL-object proxies, so an // annotation here would not create a transaction. The explicit boundary also drains deferred // credential/cache work only after the restore commits. - return executeInTransaction(() -> restoreEntityInternal(updatedBy, id)); + return executeEntityWrite( + List.of(find(id, ALL)), + () -> executeInTransaction(() -> restoreEntityInternal(updatedBy, id))); } private PutResponse restoreEntityInternal(String updatedBy, UUID id) { @@ -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")) { @@ -11432,6 +11501,21 @@ public final EventType getChangeType() { return incrementalFieldsChanged() ? ENTITY_UPDATED : ENTITY_NO_CHANGE; } + private void includeRestoration(final T beforeRestore) { + if (Boolean.TRUE.equals(beforeRestore.getDeleted())) { + // The restore already persisted its version; combine its delta only in this request's + // event. + incrementalChangeDescription = + JsonUtils.deepCopy(incrementalChangeDescription, ChangeDescription.class); + incrementalChangeDescription.setPreviousVersion(beforeRestore.getVersion()); + incrementalChangeDescription + .getFieldsUpdated() + .add(new FieldChange().withName(FIELD_DELETED).withOldValue(true).withNewValue(false)); + updated.setIncrementalChangeDescription(incrementalChangeDescription); + entityChanged = true; + } + } + public final boolean recordChange(String field, K orig, K updated) { return recordChange(field, orig, updated, false, objectMatch, true); } @@ -14281,13 +14365,15 @@ && isSourceHashUnchanged(entity, hydratedOriginalByFqn, updateFrequencyByFqn)) { entity.setUpdatedBy(userName); entity.setUpdatedAt(System.currentTimeMillis()); + final T beforeRestore = original; if (Boolean.TRUE.equals(original.getDeleted())) { - restoreEntity(entity.getUpdatedBy(), original.getId()); + original = restoreForUpdate(original, entity); } EntityUpdater updater = getUpdater(original, entity, Operation.PUT, null); updater.setOverrideMetadata(overrideMetadata); updater.updateWithDeferredStore(); + updater.includeRestoration(beforeRestore); updaters.add(updater); } catch (Exception e) { failedRequests.add( 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/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..c6b3292deb87 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyEntityRepository.java @@ -0,0 +1,221 @@ +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 "owners,reviewers"; + } + + @Override + protected boolean requiresParentForInheritance(final T entity, final Fields fields) { + return entity.getOntology() != null + && (fields.contains("reviewers") || requiresParentForOwnersOrDomains(entity, fields)); + } + + @Override + protected void applyInheritance( + final T entity, final Fields fields, final EntityInterface parent) { + inheritOwners(entity, fields, parent); + inheritReviewers(entity, fields, parent); + } + + @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) { + return new OntologyUpdater(original, updated, operation, source); + } + + 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("owners,reviewers")); + 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..cb07c7271ee2 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReferenceDAO.java @@ -0,0 +1,131 @@ +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 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..cb4ff133a712 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReleaseRepository.java @@ -0,0 +1,410 @@ +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"); + } + final WorkflowDefinition workflow = requireReviewWorkflow(task); + final OntologyReviewTarget target = + OntologyReview.target(entity) + .withWorkflowDefinitionVersion(workflow.getVersion()) + .withWorkflowDefinitionDigest(OntologyReview.workflowDigest(workflow)); + task.setOntologyReview(target); + 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..27a83c032c05 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyRepository.java @@ -0,0 +1,89 @@ +package org.openmetadata.service.jdbi3; + +import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; + +import java.net.URI; +import java.util.List; +import java.util.UUID; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.type.OntologyReference; +import org.openmetadata.schema.type.OntologyState; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.jdbi3.CoreRelationshipDAOs.EntityRelationshipObject; +import org.openmetadata.service.jdbi3.CoreRelationshipDAOs.EntityRelationshipRecord; +import org.openmetadata.service.ontology.OntologyLayerValidator; + +@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,imports"); + } + + @Override + protected void validateDefinition(final Ontology entity) { + requireAbsoluteIri(entity.getNamespace(), "namespace"); + OntologyLayerValidator.validatePrefixes(entity.getPrefixes()); + validateImports(entity); + } + + @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"); + } + } + + private void validateImports(final Ontology ontology) { + if (!listOrEmpty(ontology.getImports()).isEmpty()) { + throw new BadRequestException("Cross-ontology imports are not supported in this release"); + } + } + + 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..d802e6d4dcfb --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyWorkingModel.java @@ -0,0 +1,224 @@ +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.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 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)); + } + } + 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..6d4937e0d5a9 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 @@ -64,6 +64,7 @@ import org.openmetadata.service.events.lifecycle.handlers.IncidentTcrsSyncHandler; import org.openmetadata.service.exception.CatalogExceptionMessage; import org.openmetadata.service.exception.EntityNotFoundException; +import org.openmetadata.service.governance.workflows.OntologyApprovalWorkflow; import org.openmetadata.service.governance.approval.ChangeRequestReview; import org.openmetadata.service.governance.approval.ChangeRequestTasks; import org.openmetadata.service.governance.workflows.WorkflowHandler; @@ -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,15 @@ protected void postUpdate(Task original, Task updated) { IncidentTcrsSyncHandler.sync(updated); } + @Override + protected void flushInOneTransaction(final Runnable work) { + executeInTransaction( + () -> { + work.run(); + return null; + }); + } + private void initializeWorkflowManagedTask(Task task, boolean update) { if (update || !shouldCreateWorkflowManagedTask(task)) { return; @@ -1653,7 +1684,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 +1790,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/OntologyChangeApplicationService.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyChangeApplicationService.java index e22527852d18..41d3ad98f29a 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 @@ -39,7 +39,6 @@ import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.jdbi3.GlossaryRepository; import org.openmetadata.service.jdbi3.GlossaryTermRepository; -import org.openmetadata.service.jdbi3.OntologyAxiomRepository; import org.openmetadata.service.jdbi3.OntologyChangeSetRepository; import org.openmetadata.service.ontology.OntologyChangeOperationExecutor.OperationOutcome; import org.openmetadata.service.util.RestUtil.PutResponse; @@ -67,10 +66,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( @@ -300,15 +297,12 @@ 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), new OntologyChangeEventPublisher(), work -> termRepository.executeInTransaction(work), 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..6a30e51b63ff 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 @@ -27,7 +27,6 @@ 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.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.OntologyAttribute; @@ -38,23 +37,19 @@ 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; public OntologyChangeOperationExecutor( final GlossaryRepository glossaryRepository, final GlossaryTermRepository termRepository, - final OntologyAxiomRepository axiomRepository, final Clock clock) { this.glossaryRepository = glossaryRepository; this.termRepository = termRepository; - this.axiomRepository = axiomRepository; this.clock = clock; } @@ -72,8 +67,8 @@ 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_AXIOM, DELETE_AXIOM -> throw new BadRequestException( + "Axioms must be authored in an ontology, not a glossary change set"); }; return outcome; } @@ -277,26 +272,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..11b951ae4844 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 @@ -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; @@ -117,8 +116,8 @@ 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_AXIOM, DELETE_AXIOM -> throw new BadRequestException( + "Axioms must be authored in an ontology, not a glossary change set"); } } @@ -155,20 +154,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 +225,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..c6a9fd644ca8 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 @@ -170,7 +170,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 +178,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 +248,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 +269,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 +289,13 @@ 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_AXIOM, DELETE_AXIOM -> throw new BadRequestException( + "Axioms must be authored in an ontology, not a glossary change set"); }; return payloadKind; } @@ -321,7 +312,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..c1dd7b6dd7e2 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseActivation.java @@ -0,0 +1,112 @@ +package org.openmetadata.service.ontology; + +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.core.Response; +import java.time.Clock; +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..f4449211138b --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseCapture.java @@ -0,0 +1,197 @@ +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("*"), Include.NON_DELETED, false); + final OntologyRelease release = header(ontology, state, access.user()); + final Members members = new Members(release.getId(), access); + 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); + 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"); + } + access.authorize().accept(entity); + final OntologyReleaseMember member = + OntologyReleaseManifest.member( + releaseId, kind, entity.getId(), JsonUtils.getObjectMapper().valueToTree(entity)); + 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..c648cf59a0f9 --- /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/OntologyReleaseManifest.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseManifest.java new file mode 100644 index 000000000000..2a78be2e92b8 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseManifest.java @@ -0,0 +1,156 @@ +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()) + || !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/OntologyReleaseReader.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseReader.java new file mode 100644 index 000000000000..672d0920ffb8 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseReader.java @@ -0,0 +1,102 @@ +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(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); + authorize.accept(definition(release, member)); + members.add(member); + } + if (!page.isEmpty()) { + 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) { + 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.getObjectMapper().convertValue(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..f9ddf47639c6 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseStructure.java @@ -0,0 +1,21 @@ +package org.openmetadata.service.ontology; + +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..b13aa887014c --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReview.java @@ -0,0 +1,95 @@ +package org.openmetadata.service.ontology; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.List; +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.governance.workflows.WorkflowDefinition; +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 static final List OPERATIONAL_FIELDS = + List.of( + "version", + "updatedAt", + "updatedBy", + "impersonatedBy", + "href", + "changeDescription", + "incrementalChangeDescription", + "entityStatus", + "reviewRevision", + "reviewAuthor", + "reviewTask", + "owners", + "reviewers", + "superClasses", + "subClasses"); + + private OntologyReview() {} + + public static void initialize(final OntologyRelease entity) { + entity.setEntityStatus(EntityStatus.DRAFT); + entity.setReviewRevision(1L); + 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()) + .withReviewRevision(entity.getReviewRevision()) + .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(entity.getReviewRevision(), target.getReviewRevision()) + || !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) { + final ObjectNode content = JsonUtils.getObjectMapper().valueToTree(entity); + content.remove(OPERATIONAL_FIELDS); + if (content.get("ontology") instanceof ObjectNode reference) { + reference.retain("id", "type"); + } + return DigestUtils.sha256Hex("ontology-review-v2\n" + OntologyJson.canonical(content)); + } + + private static UUID ontologyId(final OntologyDefinition entity) { + return entity.getOntology() == null ? entity.getId() : entity.getOntology().getId(); + } + + public static String workflowDigest(final WorkflowDefinition workflow) { + final ObjectNode policy = JsonUtils.getObjectMapper().valueToTree(workflow); + policy.retain("id", "trigger", "nodes", "edges", "config", "suspended"); + return DigestUtils.sha256Hex("ontology-workflow-v1\n" + OntologyJson.canonical(policy)); + } +} 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..9ecb2abe5d5d --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyActiveGraph.java @@ -0,0 +1,87 @@ +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 -> refresh(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 -> refresh(id, 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..5bf1a947bcac --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyReleaseExporter.java @@ -0,0 +1,30 @@ +package org.openmetadata.service.rdf; + +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/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..3ae1cbe48639 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 @@ -40,11 +40,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)); } /** 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/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/OntologyChangeSetResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyChangeSetResource.java index 57b19f90c388..7a3a102683cc 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 @@ -58,7 +58,6 @@ 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.OntologyChangeApplicationService; @@ -94,7 +93,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()); 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..deefa21ee91f --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyMapper.java @@ -0,0 +1,18 @@ +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()) + .withImports(request.getImports()) + .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..353c4d146f95 --- /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.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 governed definitions and import pins across two sealed release closures", + description = + "Omitting the base compares with an empty ontology. 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 OntologyRelease base = + baseReleaseId == null ? null : get(uriInfo, securityContext, baseReleaseId); + 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 across a sealed import closure", + 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); + authorizeMember(securityContext, 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); + members.getData().forEach(member -> authorizeMember(securityContext, member)); + return members; + } + + private void authorizeMember( + final SecurityContext securityContext, final OntologyReleaseMember member) { + OntologyResourceAccess.requireView( + authorizer, securityContext, OntologyReleaseReader.definition(member)); + } +} 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..9c590226c502 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyResource.java @@ -0,0 +1,330 @@ +/* + * 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.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.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.rdf.RdfUpdater; +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 {} + + @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..a0037808d911 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyResourceAccess.java @@ -0,0 +1,36 @@ +package org.openmetadata.service.resources.ontology; + +import jakarta.ws.rs.core.SecurityContext; +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 OntologyDefinition definition) { + final String type = definition.getEntityReference().getType(); + requireView(authorizer, context, definition, Entity.getEntityRepository(type)); + } + + private static > void requireView( + final Authorizer authorizer, + final SecurityContext context, + final OntologyDefinition definition, + final EntityRepository repository) { + final String type = repository.getEntityType(); + authorizer.authorize( + context, + new OperationContext(type, MetadataOperation.VIEW_ALL), + new ResourceContext<>(type, repository.getEntityClass().cast(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..bb09b861628b 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; @@ -85,6 +86,7 @@ import org.openmetadata.service.ResourceRegistry; import org.openmetadata.service.context.review.MemoryConflictResolution; import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.governance.workflows.OntologyApprovalWorkflow; import org.openmetadata.service.governance.approval.ChangeRequestReview; import org.openmetadata.service.jdbi3.CollectionDAO; import org.openmetadata.service.jdbi3.EntityRepository; @@ -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/security/DefaultAuthorizer.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/DefaultAuthorizer.java index bf8cc83449a7..bfba6b07bb71 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/DefaultAuthorizer.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/DefaultAuthorizer.java @@ -36,7 +36,7 @@ @Slf4j public class DefaultAuthorizer implements Authorizer { - private static final DefaultAuthorizer DIRECT_READ_AUTHORIZER = new DefaultAuthorizer(); + private static final DefaultAuthorizer DIRECT_AUTHORIZER = new DefaultAuthorizer(); @Override public void init(OpenMetadataApplicationConfig config) { @@ -136,12 +136,12 @@ public void authorize( } } - /** Authorize a resolved user on read paths that do not carry the JAX-RS security context. */ + /** Authorize a resolved user on paths that do not carry the JAX-RS security context. */ public static void authorizeUser( String userName, OperationContext operationContext, ResourceContextInterface resourceContext) { - DIRECT_READ_AUTHORIZER.authorizeSubject( + DIRECT_AUTHORIZER.authorizeSubject( subjectContextForUserName(userName), operationContext, resourceContext); } 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..5ad16bf4fa88 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 @@ -60,6 +60,11 @@ import org.openmetadata.service.governance.workflows.WorkflowEventConsumer; import org.openmetadata.service.governance.workflows.WorkflowHandler; import org.openmetadata.service.governance.workflows.metadata.MetadataCollectionService; +import org.openmetadata.service.formatter.util.FormatterUtil; +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.TaskResolutionOutcome; import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.jdbi3.TaskRepository; import org.openmetadata.service.jdbi3.WorkflowInstanceRepository; @@ -202,6 +207,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 +260,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 +273,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 +314,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 +400,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 +410,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/main/java/org/openmetadata/service/tasks/TaskWorkflowLifecycleResolver.java b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowLifecycleResolver.java index 4d8d641d7632..b3602978df2b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowLifecycleResolver.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowLifecycleResolver.java @@ -36,6 +36,7 @@ import org.openmetadata.schema.type.TaskCategory; import org.openmetadata.schema.type.TaskEntityStatus; import org.openmetadata.schema.type.TaskEntityType; +import org.openmetadata.schema.type.TaskPriority; import org.openmetadata.schema.type.TaskResolutionType; import org.openmetadata.schema.utils.JsonUtils; import org.openmetadata.service.Entity; @@ -608,7 +609,9 @@ public Map toVariables() { variables.put(TASK_DESCRIPTION, task.getDescription()); variables.put(TASK_TYPE, task.getType() != null ? task.getType().value() : null); variables.put(TASK_CATEGORY, task.getCategory() != null ? task.getCategory().value() : null); - variables.put(TASK_PRIORITY, task.getPriority() != null ? task.getPriority().value() : null); + variables.put( + TASK_PRIORITY, + task.getPriority() != null ? task.getPriority().value() : TaskPriority.Medium.value()); variables.put(TASK_PAYLOAD, serializeWorkflowVariable(task.getPayload())); variables.put(TASK_DUE_DATE, task.getDueDate()); variables.put( 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..3befe88c7a0d --- /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().setReviewRevision(2L)); + 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).withReviewRevision(1L)); + } +} 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..0e1bbdd9a4f5 --- /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().setReviewRevision(2L); + 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()) + .withReviewRevision(1L) + .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/EntityRepositoryBulkEventTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryBulkEventTest.java new file mode 100644 index 000000000000..fdea69057f10 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryBulkEventTest.java @@ -0,0 +1,72 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.entity.data.Topic; +import org.openmetadata.schema.type.ChangeDescription; +import org.openmetadata.schema.type.ChangeEvent; +import org.openmetadata.schema.type.EventType; +import org.openmetadata.schema.type.FieldChange; +import org.openmetadata.schema.utils.JsonUtils; + +class EntityRepositoryBulkEventTest { + @Test + void updateEventUsesTheRequestDeltaInsteadOfTheStoredVersionDelta() { + final var topic = topic(); + final var repository = mock(TopicRepository.class, CALLS_REAL_METHODS); + final var event = + JsonUtils.readValue( + repository + .buildChangeEventJsonForBulkOperation(topic, EventType.ENTITY_UPDATED, "admin") + .orElseThrow(), + ChangeEvent.class); + + assertEquals(topic.getIncrementalChangeDescription(), event.getChangeDescription()); + assertEquals(0.2, event.getPreviousVersion()); + assertEquals(0.3, topic.getChangeDescription().getPreviousVersion()); + } + + @Test + void restoreEventRetainsTheStoredDeltaAndNoChangeProducesNoEvent() { + final var topic = topic(); + final var repository = mock(TopicRepository.class, CALLS_REAL_METHODS); + final var event = + JsonUtils.readValue( + repository + .buildChangeEventJsonForBulkOperation(topic, EventType.ENTITY_RESTORED, "admin") + .orElseThrow(), + ChangeEvent.class); + + assertEquals(topic.getChangeDescription(), event.getChangeDescription()); + assertEquals(0.3, event.getPreviousVersion()); + assertTrue( + repository + .buildChangeEventJsonForBulkOperation(topic, EventType.ENTITY_NO_CHANGE, "admin") + .isEmpty()); + } + + private static Topic topic() { + return new Topic() + .withId(UUID.randomUUID()) + .withName("orders") + .withFullyQualifiedName("service.orders") + .withUpdatedAt(1L) + .withVersion(0.4) + .withChangeDescription(new ChangeDescription().withPreviousVersion(0.3)) + .withIncrementalChangeDescription( + new ChangeDescription() + .withPreviousVersion(0.2) + .withFieldsUpdated( + List.of( + new FieldChange() + .withName("deleted") + .withOldValue(true) + .withNewValue(false)))); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryRestoreTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryRestoreTest.java index e315f0f55820..45e71223ae57 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryRestoreTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryRestoreTest.java @@ -713,7 +713,6 @@ void bulkHardDeleteSubtree_cascadeCovered_skipsPerEntityFqnCleanupAndBatchesUsag // Per-entity FQN-keyed cleanup is skipped (covered by the ancestor's prefix cascade). verify(fieldRelationshipDAO, never()).deleteAllByPrefix(any()); verify(tagUsageDAO, never()).deleteTagLabelsByTargetPrefix(any()); - verify(tagUsageDAO, never()).deleteTagLabelsByFqn(any()); // usage is id-keyed: cleared once, batched, never per-entity. verify(usageDAO, never()).delete(any(UUID.class)); verify(usageDAO, times(1)).deleteByIds(anyList()); 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..c51b924e29d9 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyActiveGraphStorageTest.java @@ -0,0 +1,124 @@ +package org.openmetadata.service.jdbi3; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +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 jakarta.json.Json; +import java.time.Clock; +import java.util.UUID; +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.ontology.OntologyReleaseActivation; +import org.openmetadata.service.rdf.OntologyActiveGraph; +import org.openmetadata.service.rdf.storage.RdfStorageInterface; + +class OntologyActiveGraphStorageTest { + @ParameterizedTest + @EnumSource( + value = ConnectionType.class, + names = {"MYSQL", "POSTGRES"}) + void projectionFollowsOnlyTheActiveReleaseAndRebuildRemovesArchivedGraphs( + ConnectionType dialect) { + withCapture( + dialect, + fixture -> { + 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 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..d718a44b00bc --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyCaptureFixture.java @@ -0,0 +1,128 @@ +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", 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..edbd80a6450a --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyEntityDAOTest.java @@ -0,0 +1,1076 @@ +/* + * 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")) { + final String id = UUID.randomUUID().toString(); + final String json = + """ + {"id":"%s","name":"%s","state":"DRAFT","updatedAt":1, + "updatedBy":"admin","deleted":false,"operations":[{"operationType":"%s"}]} + """ + .formatted(id, operation, operation); + 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, 'glossary', 'ontologyChangeSet', 0)") + .bind("parent", glossaryId) + .bind("child", id) + .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("UPSERT_TERM"), + handle + .createQuery("SELECT name FROM ontology_change_set_entity") + .mapTo(String.class) + .list()); + assertEquals( + 1, + handle + .createQuery("SELECT COUNT(*) FROM entity_relationship WHERE fromId = :id") + .bind("id", glossaryId) + .mapTo(Integer.class) + .one()); + assertEquals( + 1, + 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.getReviewRevision()); + 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).withReviewRevision(2L).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.getReviewRevision(), + assertDoesNotThrow(() -> review.get(10, TimeUnit.SECONDS)).getReviewRevision()); + } + } + }); + } + + 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 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) + .withReviewRevision(1L) + .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) + .withReviewRevision(1L) + .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..e800686aafc1 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyHierarchyStorageTest.java @@ -0,0 +1,289 @@ +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.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.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.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 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 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..0412a8d8c39c --- /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", + definition -> { + if (definition instanceof OntologyRelease) { + 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(3, 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..8d0ef5e3acf6 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseComparisonStorageTest.java @@ -0,0 +1,93 @@ +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, + definition -> { + if (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..a61dda323c07 --- /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) \\(.*")) + .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/migration/utils/v210/FlowableCharsetMigrationTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v210/FlowableCharsetMigrationTest.java index e6556d1c8a71..7e8b65fcb018 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v210/FlowableCharsetMigrationTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v210/FlowableCharsetMigrationTest.java @@ -59,7 +59,11 @@ void mysqlUpgradeChangesTheCurrentDatabaseDefault() { final List statements = MigrationFile.parseSQLFile(schemaChanges.toFile(), MYSQL); - assertTrue(statements.getLast().endsWith(ALTER_CURRENT_DATABASE), statements.getLast()); + assertEquals( + 1, + statements.stream() + .filter(statement -> statement.endsWith(ALTER_CURRENT_DATABASE)) + .count()); } @Test 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/OntologyReviewTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReviewTest.java new file mode 100644 index 000000000000..76e7f485f103 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReviewTest.java @@ -0,0 +1,163 @@ +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.governance.workflows.WorkflowDefinition; +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.getReviewRevision(), reassigned.getReviewRevision()); + assertEquals(approved.getReviewTask(), reassigned.getReviewTask()); + } + + @Test + void creationStartsInDraftRegardlessOfRequestedStatus() { + for (EntityStatus requested : EntityStatus.values()) { + OntologyRelease ontology = ontology().withEntityStatus(requested); + OntologyReview.initialize(ontology); + assertEquals(EntityStatus.DRAFT, ontology.getEntityStatus()); + assertEquals(1L, ontology.getReviewRevision()); + 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) + .withReviewRevision(1L) + .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)); + } + + @Test + void workflowFingerprintDetectsPolicyChangesWithinOneVersion() { + WorkflowDefinition original = + new WorkflowDefinition().withId(UUID.randomUUID()).withVersion(0.1).withSuspended(false); + WorkflowDefinition changed = + JsonUtils.deepCopy(original, WorkflowDefinition.class).withSuspended(true); + WorkflowDefinition audited = + JsonUtils.deepCopy(original, WorkflowDefinition.class) + .withUpdatedAt(123L) + .withUpdatedBy("admin"); + + assertNotEquals( + OntologyReview.workflowDigest(original), OntologyReview.workflowDigest(changed)); + assertEquals(OntologyReview.workflowDigest(original), OntologyReview.workflowDigest(audited)); + } + + 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) + .withReviewRevision(1L) + .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..5f04da7c004d 100644 --- a/openmetadata-service/src/test/resources/rdf/projection-entities.json +++ b/openmetadata-service/src/test/resources/rdf/projection-entities.json @@ -1,4 +1,17 @@ [ + { + "type": "task", + "fields": { + "ontologyReview": { + "entityId": "00000000-0000-0000-0000-000000000001", + "reviewRevision": 1, + "contentDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "author": "author", + "workflowDefinitionVersion": 0.1, + "workflowDefinitionDigest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + } + }, { "type": "table", "fields": { @@ -14,7 +27,8 @@ "tags": [ {"tagFQN": "PII.Sensitive", "source": "Classification", "labelType": "Manual", "state": "Confirmed", "description": "Personal data"}, {"tagFQN": "Business.Customer", "source": "Glossary", "labelType": "Derived", "state": "Suggested"}, - {"tagFQN": "Tier.Tier1", "source": "Classification"} + {"tagFQN": "Tier.Tier1", "source": "Classification"}, + {"tagFQN": "Sales.Customer", "source": "Ontology", "metadata": {"ontology": {"ontologyId": "00000000-0000-0000-0000-000000000004", "conceptId": "00000000-0000-0000-0000-000000000005"}}} ], "certification": {"tagLabel": {"tagFQN": "Certification.Gold", "source": "Classification"}, "appliedDate": 1000, "expiryDate": 2000}, "lifeCycle": { 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..87bce9f1d19a --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntology.json @@ -0,0 +1,52 @@ +{ + "$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" + }, + "imports": { + "$ref": "../../entity/data/ontology.json#/properties/imports" + } + }, + "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..12ee94268839 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,10 +45,6 @@ "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" }, @@ -64,7 +59,7 @@ "name", "displayName", "description", - "glossary", + "ontology", "axiomType", "subjectIri", "provenance" 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..2394ea2a29aa --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyConcept.json @@ -0,0 +1,54 @@ +{ + "$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" + }, + "reviewers": { + "$ref": "../../entity/data/ontologyConcept.json#/properties/reviewers" + }, + "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..b87f7f413012 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyProperty.json @@ -0,0 +1,75 @@ +{ + "$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" + }, + "reviewers": { + "$ref": "../../entity/data/ontologyProperty.json#/properties/reviewers" + }, + "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..2c47236b4e50 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontology.json @@ -0,0 +1,100 @@ +{ + "$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": [] + }, + "imports": { + "description": "Cross-ontology imports are not supported in this release.", + "type": "array", + "items": { + "$ref": "../../type/entityReference.json" + }, + "maxItems": 0, + "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..fc8f09bcd9ff 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,9 +69,6 @@ "provenance": { "$ref": "../../type/termRelation.json#/definitions/provenance" }, - "entityStatus": { - "$ref": "../../type/status.json" - }, "owners": { "$ref": "../../type/entityReferenceList.json" }, @@ -114,12 +111,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/ontologyConcept.json b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyConcept.json new file mode 100644 index 000000000000..ad4e4c41bfb7 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyConcept.json @@ -0,0 +1,118 @@ +{ + "$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" + }, + "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" + }, + "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..f2663a74b31c --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyProperty.json @@ -0,0 +1,169 @@ +{ + "$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" + }, + "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" + }, + "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..db62d999b3d2 --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyRelease.json @@ -0,0 +1,159 @@ +{ + "$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" + }, + "reviewRevision": { + "description": "Server-managed content revision. Every governed edit invalidates the previous review.", + "type": "integer", + "minimum": 1, + "existingJavaType": "java.lang.Long" + }, + "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, payload digests, and the pinned import closure.", + "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/policies/accessControl/resourceDescriptor.json b/openmetadata-spec/src/main/resources/json/schema/entity/policies/accessControl/resourceDescriptor.json index 5cb225950976..438041a9f972 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/policies/accessControl/resourceDescriptor.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/policies/accessControl/resourceDescriptor.json @@ -45,6 +45,7 @@ "EditStatus", "EditTags", "EditGlossaryTerms", + "EditOntologyConcepts", "EditTeams", "EditTier", "EditCertification", 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/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..e7b4a45dbf0f --- /dev/null +++ b/openmetadata-spec/src/main/resources/json/schema/type/ontologyReviewTarget.json @@ -0,0 +1,53 @@ +{ + "$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" + }, + "reviewRevision": { + "type": "integer", + "minimum": 1, + "existingJavaType": "java.lang.Long" + }, + "contentDigest": { + "description": "SHA-256 of ontology-review-v2 canonical content.", + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "author": { + "type": "string" + }, + "workflowDefinitionVersion": { + "$ref": "entityHistory.json#/definitions/entityVersion" + }, + "workflowDefinitionDigest": { + "description": "SHA-256 of the workflow definition, including its approval policy.", + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "entityId", + "ontologyId", + "entityVersion", + "reviewRevision", + "contentDigest", + "author", + "workflowDefinitionVersion", + "workflowDefinitionDigest" + ], + "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..6985ae97a29a 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,18 @@ om:reviewTask a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "reviewTask" . +om:reviewRevision a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "reviewRevision" . + +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 +3904,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 +4112,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: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:imports a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "imports" . + +om:dependencies a rdf:Property ; + om:projectionStatus om:Stored ; + rdfs:label "dependencies" . + +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..9b6fdf0dd3d6 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyReview.spec.ts @@ -0,0 +1,828 @@ +/* + * 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 { Ontology } from '../../../src/generated/entity/data/ontology'; +import { OntologyAxiom } from '../../../src/generated/entity/data/ontologyAxiom'; +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 { 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 expect(page.getByTestId('mode-tab-review')).toHaveText( + 'Publish and version' + ); + await page.getByTestId('mode-tab-review').click(); + await selectOntology(page, ontology); + 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'] }, + () => { + 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-review').click(); + await selectOntology(author, ontology); + 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 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('mode-tab-edit').click(); + await author.getByTestId('ontology-add-ontology').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('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-edit').click(); + await selectOntology(author, ontology); + 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-modeling-tab-properties').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-edit').click(); + await selectOntology(author, ontology); + await author.getByTestId('ontology-modeling-tab-properties').click(); + await author + .getByRole('button', { name: 'Customer status', exact: true }) + .click(); + await expect( + propertyEditor.getByRole('textbox', { name: 'Value', exact: true }) + ).toHaveValue(' active '); + 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-modeling-tab-axioms').click(); + const axiom = author.getByTestId('ontology-axiom-panel'); + await axiom + .getByRole('textbox', { name: 'Name', exact: true }) + .fill('CustomerIsParty'); + 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('Constraint created successfully.', { exact: true }) + ).toBeVisible(); + await author.getByTestId('mode-tab-view').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` + ); + } + } + } +); 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..1c8d6f757fb9 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,81 @@ 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('requires a comment before rejecting a non-ontology approval task', async () => { + await act(async () => { + render( + , + { wrapper: MemoryRouter } + ); + }); + + expect(screen.queryByTestId('reject-button')).not.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..64adc73e8096 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,24 @@ 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 = (resolutionType: TaskResolutionType) => { + if (task.status !== TaskEntityStatus.Open || isOntologyReviewTask(task)) { + return false; + } + const transition = getTransition(resolutionType); + + return !transition?.requiresComment; + }; const updateTaskData = async ( newValue: string, @@ -260,11 +273,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 +392,7 @@ const TaskFeedCardFromTask = ({ {!isTaskTestCaseResult && hasEditAccess && !isSuggestionEmpty && ( - {task.status === TaskEntityStatus.Open && ( + {canQuickResolve(TaskResolutionType.Approved) && ( + + + ); + } + + 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(); + } + )} + + ); + }; + + 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/OntologyAxiomWorkspace/OntologyAxiomWorkspace.tsx b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomWorkspace/OntologyAxiomWorkspace.tsx new file mode 100644 index 000000000000..93cd2cdd6ab0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomWorkspace/OntologyAxiomWorkspace.tsx @@ -0,0 +1,41 @@ +/* + * 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 { usePermissionProvider } from '../../../../context/PermissionProvider/PermissionProvider'; +import { ResourceEntity } from '../../../../enums/permissions.enum'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { getDerivedPermissionFlags } from '../../../../utils/PermissionDerivation'; +import { DEFAULT_ENTITY_PERMISSION } from '../../../../utils/PermissionsUtils'; +import OntologyAxiomPanel from '../../../OntologyExplorer/OntologyAxiomPanel'; + +const OntologyAxiomWorkspace = ({ ontology }: { ontology: Ontology }) => { + const { permissions } = usePermissionProvider(); + const { canCreate } = getDerivedPermissionFlags( + permissions[ResourceEntity.ONTOLOGY_AXIOM] ?? DEFAULT_ENTITY_PERMISSION, + ontology?.deleted + ); + + return ( + + {ontology && ( + + )} + + ); +}; + +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..07e23aea6770 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.test.tsx @@ -0,0 +1,133 @@ +/* + * 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(), + 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('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..99df80deafa4 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.tsx @@ -0,0 +1,96 @@ +/* + * 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 { Layer } from '../../../../generated/api/data/createOntology'; +import { Ontology } from '../../../../generated/entity/data/ontology'; +import { createOntology } from '../../../../rest/ontologyAPI'; +import { showErrorToast } from '../../../../utils/ToastUtils'; +import { useUserTeamOptions } from '../../../Glossary/hooks/useEntityReferenceOptions'; +import { + ontologyFormFields, + OntologyFormValues, + ontologyRequest, +} from './OntologyCreateForm.utils'; + +interface OntologyCreateFormProps { + canCreate: boolean; + currentUserId?: string; + onSaved: (ontology: Ontology) => void; +} + +const OntologyCreateForm = ({ + canCreate, + currentUserId, + onSaved, +}: OntologyCreateFormProps) => { + const { t } = useTranslation(); + const owners = useUserTeamOptions(); + const reviewers = useUserTeamOptions(); + const form = useForm({ + defaultValues: { + name: '', + displayName: '', + description: '', + iri: '', + namespace: '', + layer: { id: Layer.L1, label: Layer.L1 }, + owners: [], + reviewers: [], + }, + }); + const [isSaving, setIsSaving] = useState(false); + const save = async (values: OntologyFormValues) => { + if (!canCreate) { + return; + } + setIsSaving(true); + try { + onSaved(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).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..8fff8f08ce1a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyCreateForm/OntologyCreateForm.utils.ts @@ -0,0 +1,135 @@ +/* + * 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 type { useTranslation } from 'react-i18next'; +import { + CreateOntology, + Layer, +} from '../../../../generated/api/data/createOntology'; +import { isAbsoluteOntologyIri } from '../../../../utils/governance/ontology/OntologyAuthoring.utils'; +import { EntityReferenceOption } from '../../../Glossary/AddGlossary/AddGlossary.interface'; +import { + getOwnersOrCurrentUser, + 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 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 ontologyFormFields = ( + t: ReturnType['t'], + owners: EntityReferencePicker, + reviewers: EntityReferencePicker, + disabled: boolean +): 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 }, + 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..d5ccf68421ad --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyGraphView/OntologyGraphView.tsx @@ -0,0 +1,183 @@ +/* + * 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 } 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)} + /> + ) : ( + {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..2c1b758152e4 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReleasePanel/OntologyReleaseDetails.tsx @@ -0,0 +1,153 @@ +/* + * 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..bdddfab06c31 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyReviewPanel/OntologyReviewPanel.test.tsx @@ -0,0 +1,201 @@ +/* + * 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', + reviewRevision: 1, + contentDigest: 'a'.repeat(64), + author: 'author', + workflowDefinitionVersion: 1, + workflowDefinitionDigest: 'b'.repeat(64), +}; +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/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..42e8a0e67283 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntology.ts @@ -0,0 +1,112 @@ +/* + * 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; + imports?: EntityReference[]; + iri: string; + layer?: Layer; + name: string; + namespace: string; + owners?: EntityReference[]; + prefixes?: Prefix[]; + provider?: ProviderType; + reviewers?: EntityReference[]; +} + +/** + * 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; +} + +/** + * Governance layer of an ontology model. + */ +export enum Layer { + L1 = "L1", + L2 = "L2", + L3 = "L3", +} + +/** + * 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..be7bdc694da5 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,20 @@ * 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; + reviewers?: EntityReference[]; + subjectIri: string; + targetIri?: string; } export enum AxiomType { @@ -43,24 +39,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..899a6541c73a 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 @@ -204,13 +204,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,6 +218,7 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; @@ -306,28 +305,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. * @@ -555,6 +532,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#`). 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..6e2795e84ba0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyConcept.ts @@ -0,0 +1,148 @@ +/* + * 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; + reviewers?: EntityReference[]; + 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..049e44584b24 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntologyProperty.ts @@ -0,0 +1,133 @@ +/* + * 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[]; + reviewers?: EntityReference[]; + 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..d5d517a4ecdb 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 @@ -213,13 +213,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,6 +227,7 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; @@ -315,28 +314,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. * @@ -564,6 +541,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#`). 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..e770c71d453f 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 @@ -197,13 +197,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,6 +211,7 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; @@ -299,28 +298,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. * @@ -548,6 +525,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#`). 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..69d2b8002b40 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 @@ -206,13 +206,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,6 +220,7 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; @@ -308,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. * @@ -557,6 +534,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#`). 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..b693ee31821d 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 @@ -196,13 +196,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,6 +210,7 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; @@ -298,28 +297,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. * @@ -547,6 +524,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#`). 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..34adb96afdd3 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 @@ -205,13 +205,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,6 +219,7 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; @@ -307,28 +306,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. * @@ -556,6 +533,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#`). 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/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..5df3b5869973 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 @@ -203,13 +203,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,6 +217,7 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; @@ -305,28 +304,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. * @@ -554,6 +531,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#`). diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/policies/createPolicy.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/policies/createPolicy.ts index 32bd04242427..f9f2032eaf4e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/policies/createPolicy.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/policies/createPolicy.ts @@ -174,6 +174,7 @@ export enum Operation { EditKnowledgePanel = "EditKnowledgePanel", EditLifeCycle = "EditLifeCycle", EditLineage = "EditLineage", + EditOntologyConcepts = "EditOntologyConcepts", EditOwners = "EditOwners", EditPage = "EditPage", EditPolicy = "EditPolicy", 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..4f9e1aca2f22 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontology.ts @@ -0,0 +1,197 @@ +/* + * 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; + /** + * Cross-ontology imports are not supported in this release. + */ + imports?: EntityReference[]; + 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; +} + +/** + * 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; +} + +/** + * Governance layer of an ontology model. + */ +export enum Layer { + L1 = "L1", + L2 = "L2", + L3 = "L3", +} + +/** + * 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..4ac59a1e2312 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,6 +34,7 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; @@ -120,22 +119,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..3faddee6f805 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 @@ -318,13 +318,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,6 +332,7 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; @@ -355,28 +354,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. * @@ -604,6 +581,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#`). 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..eec4e0803ffa --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyConcept.ts @@ -0,0 +1,235 @@ +/* + * 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; + reviewers?: EntityReference[]; + /** + * 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..c016db672a33 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyProperty.ts @@ -0,0 +1,237 @@ +/* + * 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[]; + reviewers?: EntityReference[]; + /** + * 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..8fb0c8dcdf01 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/data/ontologyRelease.ts @@ -0,0 +1,243 @@ +/* + * 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, payload digests, and + * the pinned import closure. + */ + 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[]; + /** + * Server-managed content revision. Every governed edit invalidates the previous review. + */ + reviewRevision?: number; + /** + * 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/policies/accessControl/resourceDescriptor.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourceDescriptor.ts index 87b401afaf0b..ec7ea2ceefa5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourceDescriptor.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourceDescriptor.ts @@ -56,6 +56,7 @@ export enum Operation { EditKnowledgePanel = "EditKnowledgePanel", EditLifeCycle = "EditLifeCycle", EditLineage = "EditLineage", + EditOntologyConcepts = "EditOntologyConcepts", EditOwners = "EditOwners", EditPage = "EditPage", EditPolicy = "EditPolicy", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourcePermission.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourcePermission.ts index c2cea8f4475a..5128d74d431e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourcePermission.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourcePermission.ts @@ -98,6 +98,7 @@ export enum Operation { EditKnowledgePanel = "EditKnowledgePanel", EditLifeCycle = "EditLifeCycle", EditLineage = "EditLineage", + EditOntologyConcepts = "EditOntologyConcepts", EditOwners = "EditOwners", EditPage = "EditPage", EditPolicy = "EditPolicy", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/rule.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/rule.ts index 568b2588db40..170a198265ba 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/rule.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/rule.ts @@ -81,6 +81,7 @@ export enum Operation { EditKnowledgePanel = "EditKnowledgePanel", EditLifeCycle = "EditLifeCycle", EditLineage = "EditLineage", + EditOntologyConcepts = "EditOntologyConcepts", EditOwners = "EditOwners", EditPage = "EditPage", EditPolicy = "EditPolicy", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/policy.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/policy.ts index bccb58e855d2..f2f2ee9003f0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/policy.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/policy.ts @@ -326,6 +326,7 @@ export enum Operation { EditKnowledgePanel = "EditKnowledgePanel", EditLifeCycle = "EditLifeCycle", EditLineage = "EditLineage", + EditOntologyConcepts = "EditOntologyConcepts", EditOwners = "EditOwners", EditPage = "EditPage", EditPolicy = "EditPolicy", 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..aec3538980bf 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,34 @@ 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 ontology-review-v2 canonical content. + */ + 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; + reviewRevision: number; + /** + * SHA-256 of the workflow definition, including its approval policy. + */ + workflowDefinitionDigest: string; + workflowDefinitionVersion: number; +} + /** * 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..f061f81893fb 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 @@ -186,13 +186,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,6 +200,7 @@ export interface OntologyAxiom { incrementalChangeDescription?: ChangeDescription; literal?: Literal; name: string; + ontology: EntityReference; owners?: EntityReference[]; propertyIri?: string; provenance: Provenance; @@ -288,28 +287,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. * @@ -537,6 +514,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#`). 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..a1a131de33e8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReleaseEvent.ts @@ -0,0 +1,168 @@ +/* + * 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 ontology-review-v2 canonical content. + */ + 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; + reviewRevision: number; + /** + * SHA-256 of the workflow definition, including its approval policy. + */ + workflowDefinitionDigest: string; + workflowDefinitionVersion: number; +} 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..7196bf4fe2d8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReviewTarget.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. + */ +/** + * Server-captured immutable ontology release reviewed by an existing approval workflow task. + */ +export interface OntologyReviewTarget { + author: string; + /** + * SHA-256 of ontology-review-v2 canonical content. + */ + 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; + reviewRevision: number; + /** + * SHA-256 of the workflow definition, including its approval policy. + */ + workflowDefinitionDigest: string; + workflowDefinitionVersion: number; +} 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 df43dfe89608..8bd17d3efcc5 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", @@ -999,6 +1001,7 @@ "disabled-application-plural": "التطبيقات المعطّلة", "discard": "تجاهل", "discover": "اكتشاف", + "disjoint-properties": "خصائص متنافية", "dismiss": "تجاهل", "display-name": "اسم العرض", "display-name-lowercase": "اسم العرض", @@ -2138,11 +2141,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": "أصلي", @@ -2332,8 +2337,10 @@ "ontology-explorer": "مستكشف الأنطولوجيا", "ontology-health": "حالة الأنطولوجيا", "ontology-inferred": "الأنطولوجيا · مستنتَجة", + "ontology-iri": "IRI للأنطولوجيا", "ontology-library": "مكتبة الأنطولوجيا", "ontology-relation-plural": "علاقات الـ Ontology", + "ontology-release": "إصدار الأنطولوجيا", "ontology-studio": "استوديو الأنطولوجيا", "open": "فتح", "open-entity": "فتح {{entity}}", @@ -2585,6 +2592,9 @@ "propagated": "منتشر", "properties": "الخصائص", "property": "خاصية", + "property-chain": "سلسلة الخصائص", + "property-domain": "فئات المجال", + "property-iri": "IRI للخاصية", "property-plural": "Properties", "propose-term": "اقتراح مصطلح", "proposed-by": "مقترح من", @@ -2760,6 +2770,7 @@ "reposition": "إعادة تموضع", "repository-url": "رابط المستودع", "request": "طلب", + "request-approval": "طلب الموافقة", "request-created-by": "Request created by", "request-data-access": "طلب الوصول إلى البيانات", "request-method": "طريقة الطلب", @@ -2944,6 +2955,7 @@ "schema-text": "نص المخطط", "schema-type": "نوع المخطط", "schema-validation": "التحقق من المخطط", + "scheme-iri": "معرّف IRI لمخطط المفاهيم", "scim-token": "رمز SCIM", "scope": "النطاق", "scope-and-behaviour": "النطاق والسلوك", @@ -3264,6 +3276,7 @@ "submit-review": "إرسال المراجعة", "submit-review-count": "إرسال المراجعة ({{count}})", "submitted-by": "قُدم بواسطة", + "submitted-definition": "التعريف المقدّم", "submitted-on": "قُدم في", "submitting": "جارٍ الإرسال", "subscription": "اشتراك", @@ -3580,6 +3593,7 @@ "unprocessed": "غير معالج", "unreadable-event": "حدث غير قابل للقراءة", "unrelated": "غير مرتبط", + "unresolved": "Unresolved", "unsaved": "غير محفوظ", "unsaved-changes": "تغييرات غير محفوظة", "unsupported": "غير مدعوم", @@ -5312,6 +5326,8 @@ "ontology-ai-select-glossary": "حدد مسرد مصطلحات واحدًا لتقييد نطاق اقتراحات الذكاء الاصطناعي والتخويل.", "ontology-ai-standards-placeholder": "أدخل معايير مفصولة بفواصل، مثل FIBO وFHIR وschema.org", "ontology-edit-held-by": "يقوم {{holder}} بتحرير هذا المسرد في جلسة أخرى. أغلق تلك الجلسة أو انتظر دقيقة حتى تنتهي صلاحيتها، ثم حاول مرة أخرى.", + "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "لم يتم التحقق من الاتساق المنطقي لهذا الإصدار.", "ontology-empty-description": "تلتقط الأنطولوجيا العلاقات بين مصطلحاتك — ما ينتمي إلى ماذا، وما يُشتق من ماذا — بحيث تصبح مفرداتك نموذجًا مترابطًا لعملك، وليست مجرد قائمة أبجدية.", "ontology-empty-title": "حوّل مسردك إلى خريطة توضح كيفية ترابط المفاهيم", "ontology-format-csv-description": "تنقّل ثنائي الاتجاه للمصطلحات والعلاقات", @@ -5319,10 +5335,12 @@ "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-library-description": "أنطولوجيات جاهزة ومتوافقة مع المعايير. ثبّتها كما هي، أو اختر الوحدات أولاً.", + "ontology-iri-per-line": "معرّف IRI واحد في كل سطر؛ يُحفظ ترتيب سلسلة الخصائص.", "ontology-library-load-error": "تعذّر تحميل مكتبة الأنطولوجيا.", "ontology-not-connected-description": "لا توجد علاقات لهذه المصطلحات بعد. اربطها لوضعها في الأنطولوجيا.", "ontology-pack-dry-run-success": "اكتمل التشغيل التجريبي", @@ -5344,6 +5362,8 @@ "ontology-relation-subtype": "{{from}} هو {{to}}", "ontology-relationships-feature-description": "اربط المصطلحات بمعنى: «مرتبط بـ» أو «جزء من» أو أنواعك الخاصة مثل «محسوب من».", "ontology-studio-description": "نمذجة مفاهيم الأعمال وكيفية ارتباطها ببعضها.", + "ontology-remove-reused-name": "أزل المفهوم المحفوظ أولاً لتعيين مفهوم مختلف بالاسم نفسه.", + "ontology-review-unavailable": "تعذّر تحميل النسخة المقدّمة. قد تكون غير متاحة أو قد لا تملك إذنًا لقراءتها.", "ontology-studio-edit-coming-soon": "تأليف العلاقات المرئي قادم قريبًا. في الوقت الحالي، استكشف رسم الأنطولوجيا البياني أو نفّذ استعلامات SPARQL.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", "ontology-unlocks-feature-description": "تتبّع أي مصطلح حتى أصوله وأجزائه وتبعياته — سياق كامل، وليس تعريفًا منعزلًا.", 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 d204cb405261..9c0ed8bc83d7 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", @@ -999,6 +1001,7 @@ "disabled-application-plural": "Deaktivierte Anwendungen", "discard": "Verwerfen", "discover": "Entdecken", + "disjoint-properties": "Disjunkte Eigenschaften", "dismiss": "Verwerfen", "display-name": "Anzeigename", "display-name-lowercase": "anzeigename", @@ -2138,11 +2141,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", @@ -2332,8 +2337,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", @@ -2585,6 +2592,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", @@ -2760,6 +2770,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", @@ -2944,6 +2955,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", @@ -3264,6 +3276,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", @@ -3580,6 +3593,7 @@ "unprocessed": "Unverarbeitet", "unreadable-event": "Nicht lesbares Ereignis", "unrelated": "Ohne Bezug", + "unresolved": "Unresolved", "unsaved": "Nicht gespeichert", "unsaved-changes": "Nicht gespeicherte Änderungen", "unsupported": "Nicht unterstützt", @@ -5312,6 +5326,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "Die logische Konsistenz dieser Version wurde noch nicht geprüft.", "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", "ontology-format-csv-description": "Bidirektionaler Austausch für Begriffe & Beziehungen", @@ -5319,10 +5335,12 @@ "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-library-description": "Vorgefertigte, standardkonforme Ontologien. Unverändert installieren oder zuerst Module auswählen.", + "ontology-iri-per-line": "Eine IRI pro Zeile; die Reihenfolge der Eigenschaftskette bleibt erhalten.", "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.", "ontology-pack-dry-run-success": "Probelauf abgeschlossen", @@ -5344,6 +5362,8 @@ "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-studio-description": "Modellieren Sie Geschäftskonzepte und ihre Beziehungen zueinander.", + "ontology-remove-reused-name": "Entfernen Sie zuerst das gespeicherte Konzept, um ein anderes Konzept mit demselben Namen zuzuweisen.", + "ontology-review-unavailable": "Die eingereichte Version konnte nicht geladen werden. Sie ist möglicherweise nicht verfügbar oder Sie haben keine Leseberechtigung.", "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.", "ontology-unlocks-feature-description": "Verfolgen Sie jeden Begriff bis zu seinen übergeordneten Begriffen, Bestandteilen und Abhängigkeiten – vollständiger Kontext, nicht nur eine isolierte Definition.", 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 e528f9d59569..be6bb9feb07b 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", @@ -999,6 +1001,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", @@ -2138,11 +2141,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", @@ -2332,8 +2337,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}}", @@ -2585,6 +2592,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", @@ -2760,6 +2770,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", @@ -2944,6 +2955,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", @@ -3264,6 +3276,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", @@ -3580,6 +3593,7 @@ "unprocessed": "Unprocessed", "unreadable-event": "Unreadable Event", "unrelated": "Unrelated", + "unresolved": "Unresolved", "unsaved": "Unsaved", "unsaved-changes": "Unsaved changes", "unsupported": "Unsupported", @@ -5312,6 +5326,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "Logical consistency has not been checked for this release.", "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", "ontology-format-csv-description": "Bulk round-trip for terms & relations", @@ -5319,9 +5335,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.", @@ -5344,6 +5362,8 @@ "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-studio-description": "Model business concepts and how they relate.", + "ontology-remove-reused-name": "Remove the saved concept first to assign a different concept with the same name.", + "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-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.", "ontology-unlocks-feature-description": "Follow any term to its parents, parts, and dependencies — full context, not a lone definition.", 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 6ca49743c452..53fb97375b30 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", @@ -999,6 +1001,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", @@ -2138,11 +2141,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", @@ -2332,8 +2337,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}}", @@ -2585,6 +2592,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", @@ -2760,6 +2770,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", @@ -2944,6 +2955,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", @@ -3264,6 +3276,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", @@ -3580,6 +3593,7 @@ "unprocessed": "No Procesado", "unreadable-event": "Evento ilegible", "unrelated": "No Relacionado", + "unresolved": "Unresolved", "unsaved": "Sin guardar", "unsaved-changes": "Cambios sin guardar", "unsupported": "No compatible", @@ -5312,6 +5326,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "No se ha comprobado la coherencia lógica de esta versión.", "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", "ontology-format-csv-description": "Ida y vuelta masiva para términos y relaciones", @@ -5319,10 +5335,12 @@ "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-library-description": "Ontologías predefinidas y alineadas con estándares. Instálalas tal cual o elige primero los módulos.", + "ontology-iri-per-line": "Un IRI por línea; se conserva el orden de la cadena de propiedades.", "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.", "ontology-pack-dry-run-success": "Ejecución de prueba completada", @@ -5344,6 +5362,8 @@ "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-studio-description": "Modela conceptos de negocio y cómo se relacionan.", + "ontology-remove-reused-name": "Elimina primero el concepto guardado para asignar otro concepto con el mismo nombre.", + "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-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.", "ontology-unlocks-feature-description": "Sigue cualquier término hasta sus padres, partes y dependencias: contexto completo, no una definición aislada.", 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 36bc67421aec..510e1587ad6e 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", @@ -999,6 +1001,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", @@ -2138,11 +2141,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", @@ -2332,8 +2337,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}}", @@ -2585,6 +2592,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", @@ -2760,6 +2770,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", @@ -2944,6 +2955,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", @@ -3264,6 +3276,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", @@ -3580,6 +3593,7 @@ "unprocessed": "Non Traité", "unreadable-event": "Événement illisible", "unrelated": "Non Lié", + "unresolved": "Unresolved", "unsaved": "Non enregistré", "unsaved-changes": "Modifications non enregistrées", "unsupported": "Non pris en charge", @@ -5312,6 +5326,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "La cohérence logique de cette version n’a pas été vérifiée.", "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", "ontology-format-csv-description": "Aller-retour groupé pour les termes et relations", @@ -5319,10 +5335,12 @@ "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-library-description": "Ontologies prêtes à l'emploi, conformes aux standards. Installez-les telles quelles ou choisissez d'abord les modules.", + "ontology-iri-per-line": "Un IRI par ligne ; l’ordre de la chaîne de propriétés est conservé.", "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.", "ontology-pack-dry-run-success": "Exécution à blanc terminée", @@ -5344,6 +5362,8 @@ "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-studio-description": "Modélisez les concepts métier et leurs relations.", + "ontology-remove-reused-name": "Supprimez d’abord le concept enregistré pour attribuer un autre concept portant le même nom.", + "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-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.", "ontology-unlocks-feature-description": "Suivez n'importe quel terme jusqu'à ses parents, ses composants et ses dépendances : un contexte complet, pas une définition isolée.", 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 71a722ecac23..7073855ce542 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", @@ -999,6 +1001,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", @@ -2138,11 +2141,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", @@ -2332,8 +2337,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}}", @@ -2585,6 +2592,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", @@ -2760,6 +2770,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", @@ -2944,6 +2955,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", @@ -3264,6 +3276,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", @@ -3580,6 +3593,7 @@ "unprocessed": "Sen procesar", "unreadable-event": "Evento ilexible", "unrelated": "Non relacionado", + "unresolved": "Unresolved", "unsaved": "Sen gardar", "unsaved-changes": "Cambios sen gardar", "unsupported": "Non compatible", @@ -5312,6 +5326,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "Non se comprobou a coherencia lóxica desta versión.", "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", "ontology-format-csv-description": "Ida e volta masiva para termos e relacións", @@ -5319,10 +5335,12 @@ "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-library-description": "Ontoloxías predefinidas e aliñadas con estándares. Instálaas tal cal ou escolle primeiro os módulos.", + "ontology-iri-per-line": "Un IRI por liña; consérvase a orde da cadea de propiedades.", "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.", "ontology-pack-dry-run-success": "Execución de proba completada", @@ -5344,6 +5362,8 @@ "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-studio-description": "Modela conceptos de negocio e como se relacionan.", + "ontology-remove-reused-name": "Elimina primeiro o concepto gardado para asignar outro concepto co mesmo nome.", + "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-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.", "ontology-unlocks-feature-description": "Segue calquera termo ata os seus pais, partes e dependencias: contexto completo, non unha definición illada.", 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 87618dc30e60..318bd04f25c8 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", @@ -999,6 +1001,7 @@ "disabled-application-plural": "אפליקציות מושבתות", "discard": "זריקה", "discover": "גלה", + "disjoint-properties": "מאפיינים זרים", "dismiss": "דחייה", "display-name": "שם הצגה", "display-name-lowercase": "שם תצוגה", @@ -2138,11 +2141,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": "מקורי", @@ -2332,8 +2337,10 @@ "ontology-explorer": "סייר אונטולוגיה", "ontology-health": "מצב האונטולוגיה", "ontology-inferred": "אונטולוגיה · מוסק", + "ontology-iri": "IRI של האונטולוגיה", "ontology-library": "ספריית אונטולוגיות", "ontology-relation-plural": "קשרי Ontology", + "ontology-release": "גרסת אונטולוגיה", "ontology-studio": "סטודיו אונטולוגיה", "open": "פתוח", "open-entity": "פתח {{entity}}", @@ -2585,6 +2592,9 @@ "propagated": "מושך", "properties": "מאפיינים", "property": "מאפיין", + "property-chain": "שרשרת מאפיינים", + "property-domain": "מחלקות תחום", + "property-iri": "IRI של המאפיין", "property-plural": "Properties", "propose-term": "הצע מונח", "proposed-by": "הוצע על ידי", @@ -2760,6 +2770,7 @@ "reposition": "שנה מיקום", "repository-url": "כתובת URL של המאגר", "request": "בקשה", + "request-approval": "בקשת אישור", "request-created-by": "בקשה נוצרה על ידי", "request-data-access": "בקש גישה לנתונים", "request-method": "שיטת בקשה", @@ -2944,6 +2955,7 @@ "schema-text": "טקסט סכמה", "schema-type": "סוג סכמה", "schema-validation": "אימות סכימה", + "scheme-iri": "IRI של סכמת המושגים", "scim-token": "אסימון SCIM", "scope": "היקף", "scope-and-behaviour": "היקף והתנהגות", @@ -3264,6 +3276,7 @@ "submit-review": "שלח סקירה", "submit-review-count": "שלח סקירה ({{count}})", "submitted-by": "הוגש על ידי", + "submitted-definition": "ההגדרה שנשלחה", "submitted-on": "הוגש ב", "submitting": "שולח", "subscription": "מינוי", @@ -3580,6 +3593,7 @@ "unprocessed": "לא מעובד", "unreadable-event": "אירוע שאינו קריא", "unrelated": "לא קשור", + "unresolved": "Unresolved", "unsaved": "לא שמור", "unsaved-changes": "שינויים שלא נשמרו", "unsupported": "לא נתמך", @@ -5312,6 +5326,8 @@ "ontology-ai-select-glossary": "בחרו מילון מונחים אחד כדי להגדיר את היקף הצעות הבינה המלאכותית וההרשאה.", "ontology-ai-standards-placeholder": "הזינו תקנים מופרדים בפסיקים, לדוגמה FIBO,‏ FHIR,‏ schema.org", "ontology-edit-held-by": "{{holder}} עורך את המילון הזה בהפעלה אחרת. סגרו את ההפעלה או המתינו דקה עד שתפוג, ונסו שוב.", + "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "העקביות הלוגית של גרסה זו טרם נבדקה.", "ontology-empty-description": "אונטולוגיה לוכדת את הקשרים בין המונחים שלך — מה שייך למה, מה נגזר ממה — כך שאוצר המילים שלך הופך למודל מקושר של העסק שלך, ולא רק לרשימה אלפביתית.", "ontology-empty-title": "הפוך את מילון המונחים שלך למפה של האופן שבו מושגים מתחברים", "ontology-format-csv-description": "הלוך ושוב מרוכז למונחים ולקשרים", @@ -5319,10 +5335,12 @@ "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-library-description": "אונטולוגיות מוכנות מראש התואמות לתקנים. התקינו כפי שהן, או בחרו תחילה מודולים.", + "ontology-iri-per-line": "IRI אחד בכל שורה; סדר שרשרת המאפיינים נשמר.", "ontology-library-load-error": "לא ניתן לטעון את ספריית האונטולוגיות.", "ontology-not-connected-description": "למונחים אלה אין עדיין קשרים. חברו אותם כדי למקם אותם באונטולוגיה.", "ontology-pack-dry-run-success": "הרצת הניסיון הושלמה", @@ -5344,6 +5362,8 @@ "ontology-relation-subtype": "{{from}} הוא {{to}}", "ontology-relationships-feature-description": "קשר מונחים במשמעות: \"קשור ל\", \"חלק מ\" או סוגים משלך כמו \"מחושב מ\".", "ontology-studio-description": "מדלו מושגים עסקיים ואת הקשרים ביניהם.", + "ontology-remove-reused-name": "יש להסיר תחילה את המושג השמור כדי לשייך מושג אחר בעל אותו שם.", + "ontology-review-unavailable": "לא ניתן לטעון את הגרסה שנשלחה. ייתכן שהיא אינה זמינה או שאין לך הרשאה לקרוא אותה.", "ontology-studio-edit-coming-soon": "עריכה חזותית של קשרים תגיע בקרוב. בינתיים, חקרו את גרף האונטולוגיה או הריצו שאילתות SPARQL.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", "ontology-unlocks-feature-description": "עקוב אחר כל מונח עד להורים, לחלקים ולתלויות שלו — הקשר מלא, לא הגדרה בודדת.", 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 8ccce00ef9a2..0a296e793361 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", @@ -999,6 +1001,7 @@ "disabled-application-plural": "無効なアプリケーション", "discard": "破棄", "discover": "探索", + "disjoint-properties": "互いに素なプロパティ", "dismiss": "却下", "display-name": "表示名", "display-name-lowercase": "表示名", @@ -2138,11 +2141,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": "ネイティブ", @@ -2332,8 +2337,10 @@ "ontology-explorer": "オントロジーエクスプローラー", "ontology-health": "オントロジーの健全性", "ontology-inferred": "オントロジー・推論", + "ontology-iri": "オントロジーのIRI", "ontology-library": "オントロジーライブラリ", "ontology-relation-plural": "Ontology の関係", + "ontology-release": "オントロジーのリリース", "ontology-studio": "オントロジースタジオ", "open": "開く", "open-entity": "{{entity}}を開く", @@ -2585,6 +2592,9 @@ "propagated": "伝播済み", "properties": "プロパティ", "property": "プロパティ", + "property-chain": "プロパティチェーン", + "property-domain": "定義域のクラス", + "property-iri": "プロパティのIRI", "property-plural": "Properties", "propose-term": "用語を提案", "proposed-by": "提案者", @@ -2760,6 +2770,7 @@ "reposition": "位置変更", "repository-url": "リポジトリURL", "request": "リクエスト", + "request-approval": "承認を申請", "request-created-by": "リクエスト作成者", "request-data-access": "データアクセスを要求", "request-method": "リクエストメソッド", @@ -2944,6 +2955,7 @@ "schema-text": "スキーマテキスト", "schema-type": "スキーマタイプ", "schema-validation": "スキーマ検証", + "scheme-iri": "概念体系のIRI", "scim-token": "SCIMトークン", "scope": "スコープ", "scope-and-behaviour": "スコープと動作", @@ -3264,6 +3276,7 @@ "submit-review": "レビューを送信", "submit-review-count": "レビューを送信 ({{count}})", "submitted-by": "送信者", + "submitted-definition": "提出された定義", "submitted-on": "送信日", "submitting": "送信中", "subscription": "サブスクリプション", @@ -3580,6 +3593,7 @@ "unprocessed": "未処理", "unreadable-event": "読み取れないイベント", "unrelated": "関連なし", + "unresolved": "Unresolved", "unsaved": "未保存", "unsaved-changes": "未保存の変更", "unsupported": "サポート対象外", @@ -5312,6 +5326,8 @@ "ontology-ai-select-glossary": "AI 提案と認可の範囲を限定する用語集を 1 つ選択してください。", "ontology-ai-standards-placeholder": "標準をカンマ区切りで入力してください(例: FIBO, FHIR, schema.org)", "ontology-edit-held-by": "{{holder}} が別のセッションでこの用語集を編集しています。そのセッションを閉じるか、期限切れになるまで 1 分ほど待ってから再試行してください。", + "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "このリリースの論理的整合性は確認されていません。", "ontology-empty-description": "オントロジーは用語間の関係(何が何に属し、何が何から派生するか)を捉え、語彙を単なる五十音順のリストではなく、ビジネスのつながったモデルにします。", "ontology-empty-title": "用語集を、概念のつながりを示すマップに変えましょう", "ontology-format-csv-description": "用語と関係の一括ラウンドトリップ", @@ -5319,10 +5335,12 @@ "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-library-description": "標準に準拠した構築済みオントロジー。そのままインストールするか、先にモジュールを選択してください。", + "ontology-iri-per-line": "1行に1つのIRIを入力してください。プロパティチェーンの順序は保持されます。", "ontology-library-load-error": "オントロジーライブラリを読み込めませんでした。", "ontology-not-connected-description": "これらの用語にはまだリレーションがありません。接続してオントロジーに配置してください。", "ontology-pack-dry-run-success": "ドライランが完了しました", @@ -5344,6 +5362,8 @@ "ontology-relation-subtype": "{{from}} は {{to}} の一種", "ontology-relationships-feature-description": "「関連する」「〜の一部」など意味を持たせて用語をリンクしたり、「〜から算出」のような独自のタイプを作成できます。", "ontology-studio-description": "ビジネス概念とその関係をモデル化します。", + "ontology-remove-reused-name": "同じ名前の別の概念を割り当てるには、保存済みの概念を先に削除してください。", + "ontology-review-unavailable": "提出されたバージョンを読み込めませんでした。利用できないか、読み取り権限がない可能性があります。", "ontology-studio-edit-coming-soon": "リレーションシップのビジュアル編集は近日公開予定です。当面は、オントロジーグラフを探索するか、SPARQLクエリを実行してください。", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", "ontology-unlocks-feature-description": "任意の用語からその親・構成要素・依存関係までたどれます。孤立した定義ではなく、完全なコンテキストが得られます。", 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 f615e788f82f..7aca3bf99b4b 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", @@ -999,6 +1001,7 @@ "disabled-application-plural": "비활성화된 애플리케이션", "discard": "폐기", "discover": "발견", + "disjoint-properties": "서로소 속성", "dismiss": "무시", "display-name": "표시 이름", "display-name-lowercase": "표시 이름", @@ -2138,11 +2141,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": "네이티브", @@ -2332,8 +2337,10 @@ "ontology-explorer": "온톨로지 탐색기", "ontology-health": "온톨로지 상태", "ontology-inferred": "온톨로지 · 추론됨", + "ontology-iri": "온톨로지 IRI", "ontology-library": "온톨로지 라이브러리", "ontology-relation-plural": "Ontology 관계", + "ontology-release": "온톨로지 릴리스", "ontology-studio": "온톨로지 스튜디오", "open": "열기", "open-entity": "{{entity}} 열기", @@ -2585,6 +2592,9 @@ "propagated": "전파됨", "properties": "속성", "property": "속성", + "property-chain": "속성 체인", + "property-domain": "정의역 클래스", + "property-iri": "속성 IRI", "property-plural": "Properties", "propose-term": "용어 제안", "proposed-by": "제안자", @@ -2760,6 +2770,7 @@ "reposition": "위치 변경", "repository-url": "저장소 URL", "request": "요청", + "request-approval": "승인 요청", "request-created-by": "Request created by", "request-data-access": "데이터 접근 요청", "request-method": "요청 메서드", @@ -2944,6 +2955,7 @@ "schema-text": "스키마 텍스트", "schema-type": "스키마 유형", "schema-validation": "스키마 검증", + "scheme-iri": "개념 체계 IRI", "scim-token": "SCIM 토큰", "scope": "범위", "scope-and-behaviour": "범위 및 동작", @@ -3264,6 +3276,7 @@ "submit-review": "검토 제출", "submit-review-count": "검토 제출 ({{count}})", "submitted-by": "제출자", + "submitted-definition": "제출된 정의", "submitted-on": "제출일", "submitting": "제출 중", "subscription": "구독", @@ -3580,6 +3593,7 @@ "unprocessed": "처리되지 않음", "unreadable-event": "읽을 수 없는 이벤트", "unrelated": "관련 없음", + "unresolved": "Unresolved", "unsaved": "저장되지 않음", "unsaved-changes": "저장되지 않은 변경 사항", "unsupported": "지원되지 않음", @@ -5312,6 +5326,8 @@ "ontology-ai-select-glossary": "AI 제안과 권한 부여 범위를 지정할 용어집 하나를 선택하세요.", "ontology-ai-standards-placeholder": "쉼표로 구분된 표준을 입력하세요. 예: FIBO, FHIR, schema.org", "ontology-edit-held-by": "{{holder}}님이 다른 세션에서 이 용어집을 편집하고 있습니다. 해당 세션을 닫거나 만료될 때까지 1분 정도 기다린 후 다시 시도하세요.", + "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "이 릴리스의 논리적 일관성은 확인되지 않았습니다.", "ontology-empty-description": "온톨로지는 용어 간의 관계(무엇이 무엇에 속하고, 무엇이 무엇에서 파생되는지)를 포착하여 어휘를 단순한 알파벳순 목록이 아니라 비즈니스의 연결된 모델로 만듭니다.", "ontology-empty-title": "용어집을 개념이 연결되는 방식의 지도로 바꿔보세요", "ontology-format-csv-description": "용어 및 관계의 대량 왕복", @@ -5319,10 +5335,12 @@ "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-library-description": "표준에 맞춘 사전 구축 온톨로지입니다. 그대로 설치하거나 먼저 모듈을 선택하세요.", + "ontology-iri-per-line": "한 줄에 하나의 IRI를 입력하세요. 속성 체인 순서는 유지됩니다.", "ontology-library-load-error": "온톨로지 라이브러리를 불러올 수 없습니다.", "ontology-not-connected-description": "이 용어들에는 아직 관계가 없습니다. 연결하여 온톨로지에 배치하세요.", "ontology-pack-dry-run-success": "시험 실행 완료", @@ -5344,6 +5362,8 @@ "ontology-relation-subtype": "{{from}}은(는) {{to}}의 일종", "ontology-relationships-feature-description": "\"관련됨\", \"~의 일부\"와 같은 의미로 용어를 연결하거나 \"~에서 계산됨\"과 같은 사용자 지정 유형을 만드세요.", "ontology-studio-description": "비즈니스 개념과 그 관계를 모델링합니다.", + "ontology-remove-reused-name": "같은 이름의 다른 개념을 지정하려면 먼저 저장된 개념을 제거하세요.", + "ontology-review-unavailable": "제출된 버전을 불러올 수 없습니다. 사용할 수 없거나 읽기 권한이 없을 수 있습니다.", "ontology-studio-edit-coming-soon": "관계 시각 편집 기능이 곧 제공됩니다. 지금은 온톨로지 그래프를 탐색하거나 SPARQL 쿼리를 실행하세요.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", "ontology-unlocks-feature-description": "어떤 용어든 상위 항목, 구성 요소, 종속성까지 따라가 보세요. 단편적인 정의가 아니라 전체 맥락을 얻을 수 있습니다.", 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 f16c1a7904e5..e263c1ed2bd9 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", @@ -999,6 +1001,7 @@ "disabled-application-plural": "अक्षम अनुप्रयोग", "discard": "टाका", "discover": "शोधा", + "disjoint-properties": "परस्पर विसंगत गुणधर्म", "dismiss": "नाकारून टाका", "display-name": "प्रदर्शन नाव", "display-name-lowercase": "प्रदर्शन नाव", @@ -2138,11 +2141,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": "मूळ", @@ -2332,8 +2337,10 @@ "ontology-explorer": "ऑन्टोलॉजी एक्सप्लोरर", "ontology-health": "ऑन्टोलॉजीचे स्वास्थ्य", "ontology-inferred": "ऑन्टोलॉजी · अनुमानित", + "ontology-iri": "ऑन्टोलॉजी IRI", "ontology-library": "ऑन्टोलॉजी लायब्ररी", "ontology-relation-plural": "Ontology संबंध", + "ontology-release": "ऑन्टोलॉजी प्रकाशन", "ontology-studio": "ऑन्टोलॉजी स्टुडिओ", "open": "उघडा", "open-entity": "{{entity}} उघडा", @@ -2585,6 +2592,9 @@ "propagated": "प्रसारित", "properties": "गुणधर्म", "property": "गुणधर्म", + "property-chain": "गुणधर्म साखळी", + "property-domain": "डोमेन वर्ग", + "property-iri": "गुणधर्म IRI", "property-plural": "Properties", "propose-term": "संज्ञा सुचवा", "proposed-by": "प्रस्तावक", @@ -2760,6 +2770,7 @@ "reposition": "स्थिती बदला", "repository-url": "रेपॉझिटरी URL", "request": "विनंती", + "request-approval": "मंजुरीची विनंती करा", "request-created-by": "Request created by", "request-data-access": "डेटा प्रवेशाची विनंती करा", "request-method": "विनंती पद्धत", @@ -2944,6 +2955,7 @@ "schema-text": "योजना मजकूर", "schema-type": "योजना प्रकार", "schema-validation": "स्कीमा प्रमाणीकरण", + "scheme-iri": "संकल्पना योजनेचा IRI", "scim-token": "SCIM टोकन", "scope": "व्याप्ती", "scope-and-behaviour": "व्याप्ती आणि वर्तन", @@ -3264,6 +3276,7 @@ "submit-review": "पुनरावलोकन प्रस्तुत करा", "submit-review-count": "पुनरावलोकन प्रस्तुत करा ({{count}})", "submitted-by": "द्वारे सबमिट केले", + "submitted-definition": "सादर केलेली व्याख्या", "submitted-on": "सबमिट केले", "submitting": "सबमिट करत आहे", "subscription": "सदस्यता", @@ -3580,6 +3593,7 @@ "unprocessed": "प्रक्रिया न केलेले", "unreadable-event": "वाचता न येणारा इव्हेंट", "unrelated": "असंबंधित", + "unresolved": "Unresolved", "unsaved": "जतन न केलेले", "unsaved-changes": "जतन न केलेले बदल", "unsupported": "असमर्थित", @@ -5312,6 +5326,8 @@ "ontology-ai-select-glossary": "AI प्रस्ताव आणि अधिकृततेची व्याप्ती ठरवण्यासाठी एक शब्दकोश निवडा.", "ontology-ai-standards-placeholder": "स्वल्पविरामाने विभक्त मानके प्रविष्ट करा, उदाहरणार्थ FIBO, FHIR, schema.org", "ontology-edit-held-by": "{{holder}} दुसऱ्या सत्रात ही शब्दसूची संपादित करत आहेत. ते सत्र बंद करा किंवा ते कालबाह्य होण्यासाठी एक मिनिट थांबा, नंतर पुन्हा प्रयत्न करा.", + "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "या प्रकाशनाची तार्किक सुसंगतता तपासलेली नाही.", "ontology-empty-description": "ऑन्टोलॉजी तुमच्या संज्ञांमधील संबंध टिपते — काय कशाशी संबंधित आहे, काय कशापासून निर्माण होते — जेणेकरून तुमचा शब्दसंग्रह केवळ वर्णानुक्रमे यादी नसून तुमच्या व्यवसायाचे जोडलेले मॉडेल बनतो.", "ontology-empty-title": "तुमचा शब्दकोश संकल्पना कशा जोडल्या जातात याच्या नकाशात रूपांतरित करा", "ontology-format-csv-description": "संज्ञा आणि संबंधांसाठी मोठ्या प्रमाणात राउंड-ट्रिप", @@ -5319,10 +5335,12 @@ "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-library-description": "पूर्व-निर्मित, मानकांशी सुसंगत ऑन्टोलॉजी. जशास तसे इंस्टॉल करा, किंवा आधी मॉड्यूल निवडा.", + "ontology-iri-per-line": "प्रत्येक ओळीत एक IRI; गुणधर्म साखळीचा क्रम जतन केला जातो.", "ontology-library-load-error": "ऑन्टोलॉजी लायब्ररी लोड करता आली नाही.", "ontology-not-connected-description": "या संज्ञांना अद्याप कोणतेही संबंध नाहीत. त्यांना ऑन्टोलॉजीमध्ये स्थान देण्यासाठी जोडा.", "ontology-pack-dry-run-success": "चाचणी रन पूर्ण झाला", @@ -5344,6 +5362,8 @@ "ontology-relation-subtype": "{{from}} हा एक {{to}} आहे", "ontology-relationships-feature-description": "अर्थासह संज्ञा जोडा: \"शी संबंधित\", \"चा भाग\" किंवा \"पासून गणना केलेले\" यासारखे तुमचे स्वतःचे प्रकार.", "ontology-studio-description": "व्यवसाय संकल्पना आणि त्यांचे परस्परसंबंध मॉडेल करा.", + "ontology-remove-reused-name": "त्याच नावाची वेगळी संकल्पना नियुक्त करण्यासाठी आधी जतन केलेली संकल्पना काढून टाका.", + "ontology-review-unavailable": "सादर केलेली आवृत्ती लोड करता आली नाही. ती उपलब्ध नसू शकते किंवा तुम्हाला ती वाचण्याची परवानगी नसू शकते.", "ontology-studio-edit-coming-soon": "व्हिज्युअल रिलेशनशिप संपादन लवकरच येत आहे. सध्या, ऑन्टोलॉजी आलेख एक्सप्लोर करा किंवा SPARQL क्वेरी चालवा.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", "ontology-unlocks-feature-description": "कोणत्याही संज्ञेचा तिच्या मूळ, भाग आणि अवलंबनांपर्यंत मागोवा घ्या — संपूर्ण संदर्भ, एकाकी व्याख्या नाही.", 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 e1e916002b72..2d43ad98e956 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", @@ -999,6 +1001,7 @@ "disabled-application-plural": "Uitgeschakelde applicaties", "discard": "Weggooien", "discover": "Ontdekken", + "disjoint-properties": "Disjuncte eigenschappen", "dismiss": "Negeren", "display-name": "Weergavenaam", "display-name-lowercase": "weergavenaam", @@ -2138,11 +2141,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", @@ -2332,8 +2337,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", @@ -2585,6 +2592,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", @@ -2760,6 +2770,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", @@ -2944,6 +2955,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", @@ -3264,6 +3276,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", @@ -3580,6 +3593,7 @@ "unprocessed": "Onverwerkt", "unreadable-event": "Onleesbare gebeurtenis", "unrelated": "Niet gerelateerd", + "unresolved": "Unresolved", "unsaved": "Niet opgeslagen", "unsaved-changes": "Niet-opgeslagen wijzigingen", "unsupported": "Niet ondersteund", @@ -5312,6 +5326,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "De logische consistentie van deze release is niet gecontroleerd.", "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", "ontology-format-csv-description": "Bulk-heen-en-weer voor termen & relaties", @@ -5319,10 +5335,12 @@ "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-library-description": "Kant-en-klare, op standaarden afgestemde ontologieën. Installeer ze zoals ze zijn, of kies eerst modules.", + "ontology-iri-per-line": "Eén IRI per regel; de volgorde van de eigenschappenketen blijft behouden.", "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.", "ontology-pack-dry-run-success": "Proefuitvoering voltooid", @@ -5344,6 +5362,8 @@ "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-studio-description": "Modelleer bedrijfsconcepten en hoe ze met elkaar samenhangen.", + "ontology-remove-reused-name": "Verwijder eerst het opgeslagen concept om een ander concept met dezelfde naam toe te wijzen.", + "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-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.", "ontology-unlocks-feature-description": "Volg elke term tot aan de bovenliggende termen, onderdelen en afhankelijkheden: volledige context, geen losse definitie.", 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 5f57f948195e..e74df2bec685 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": "آبشاری", @@ -999,6 +1001,7 @@ "disabled-application-plural": "برنامه‌های غیرفعال", "discard": "رد کرنا", "discover": "کشف", + "disjoint-properties": "ویژگی‌های مجزا", "dismiss": "رد کردن", "display-name": "نام نمایشی", "display-name-lowercase": "نام نمایشی", @@ -2138,11 +2141,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": "بومی", @@ -2332,8 +2337,10 @@ "ontology-explorer": "کاوشگر هستی‌شناسی", "ontology-health": "سلامت هستی‌شناسی", "ontology-inferred": "هستان‌شناسی · استنتاج‌شده", + "ontology-iri": "IRI هستی‌شناسی", "ontology-library": "کتابخانه هستی‌شناسی", "ontology-relation-plural": "روابط Ontology", + "ontology-release": "نسخه هستی‌شناسی", "ontology-studio": "استودیو هستی‌شناسی", "open": "باز", "open-entity": "باز کردن {{entity}}", @@ -2585,6 +2592,9 @@ "propagated": "پراگت", "properties": "ویژگی‌ها", "property": "ویژگی", + "property-chain": "زنجیرهٔ ویژگی‌ها", + "property-domain": "کلاس‌های دامنه", + "property-iri": "IRI ویژگی", "property-plural": "Properties", "propose-term": "پیشنهاد اصطلاح", "proposed-by": "پیشنهادشده توسط", @@ -2760,6 +2770,7 @@ "reposition": "تغییر موقعیت", "repository-url": "آدرس مخزن", "request": "درخواست", + "request-approval": "درخواست تأیید", "request-created-by": "درخواست ایجاد شده توسط", "request-data-access": "درخواست دسترسی به داده", "request-method": "روش درخواست", @@ -2944,6 +2955,7 @@ "schema-text": "متن اسکیما", "schema-type": "نوع شِما", "schema-validation": "Validação de esquema", + "scheme-iri": "IRI طرح مفهومی", "scim-token": "توکن SCIM", "scope": "محدوده", "scope-and-behaviour": "Âmbito e comportamento", @@ -3264,6 +3276,7 @@ "submit-review": "ارسال بررسی", "submit-review-count": "ارسال بررسی ({{count}})", "submitted-by": "ارسال شده توسط", + "submitted-definition": "تعریف ارسال‌شده", "submitted-on": "ارسال شده در", "submitting": "در حال ارسال", "subscription": "اشتراک", @@ -3580,6 +3593,7 @@ "unprocessed": "پردازش‌نشده", "unreadable-event": "رویداد غیرقابل خواندن", "unrelated": "بی‌ارتباط", + "unresolved": "Unresolved", "unsaved": "Não Guardado", "unsaved-changes": "تغییرات ذخیره‌نشده", "unsupported": "پشتیبانی‌نشده", @@ -5312,6 +5326,8 @@ "ontology-ai-select-glossary": "یک واژه‌نامه را برای تعیین دامنه پیشنهادهای هوش مصنوعی و مجوز انتخاب کنید.", "ontology-ai-standards-placeholder": "استانداردها را با ویرگول جدا کنید، برای نمونه FIBO،‏ FHIR،‏ schema.org", "ontology-edit-held-by": "{{holder}} در حال ویرایش این واژه‌نامه در نشست دیگری است. آن نشست را ببندید یا یک دقیقه صبر کنید تا منقضی شود و دوباره تلاش کنید.", + "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "سازگاری منطقی این نسخه بررسی نشده است.", "ontology-empty-description": "هستان‌شناسی روابط میان اصطلاحات شما را ثبت می‌کند — چه چیزی به چه چیزی تعلق دارد، چه چیزی از چه چیزی مشتق می‌شود — تا واژگان شما به مدلی به‌هم‌پیوسته از کسب‌وکارتان تبدیل شود، نه صرفاً یک فهرست الفبایی.", "ontology-empty-title": "واژه‌نامه خود را به نقشه‌ای از چگونگی ارتباط مفاهیم تبدیل کنید", "ontology-format-csv-description": "رفت‌وبرگشت انبوه برای اصطلاحات و روابط", @@ -5319,10 +5335,12 @@ "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-library-description": "هستی‌شناسی‌های از پیش ساخته و همسو با استانداردها. همان‌طور که هستند نصب کنید یا ابتدا ماژول‌ها را انتخاب کنید.", + "ontology-iri-per-line": "در هر خط یک IRI؛ ترتیب زنجیرهٔ ویژگی‌ها حفظ می‌شود.", "ontology-library-load-error": "کتابخانه هستی‌شناسی بارگیری نشد.", "ontology-not-connected-description": "این اصطلاحات هنوز رابطه‌ای ندارند. آن‌ها را متصل کنید تا در هستی‌شناسی جای بگیرند.", "ontology-pack-dry-run-success": "اجرای آزمایشی کامل شد", @@ -5344,6 +5362,8 @@ "ontology-relation-subtype": "{{from}} یک {{to}} است", "ontology-relationships-feature-description": "اصطلاحات را با معنا پیوند دهید: «مرتبط با»، «بخشی از» یا انواع دلخواه خودتان مانند «محاسبه‌شده از».", "ontology-studio-description": "مفاهیم کسب‌وکار و نحوه ارتباط آن‌ها را مدل‌سازی کنید.", + "ontology-remove-reused-name": "ابتدا مفهوم ذخیره‌شده را حذف کنید تا بتوانید مفهوم دیگری با همان نام اختصاص دهید.", + "ontology-review-unavailable": "نسخه ارسال‌شده بارگیری نشد. ممکن است در دسترس نباشد یا مجوز خواندن آن را نداشته باشید.", "ontology-studio-edit-coming-soon": "ویرایش بصری روابط به‌زودی ارائه می‌شود. فعلاً نمودار هستی‌شناسی را کاوش کنید یا پرس‌وجوهای SPARQL را اجرا کنید.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", "ontology-unlocks-feature-description": "هر اصطلاح را تا والدین، اجزا و وابستگی‌هایش دنبال کنید — زمینهٔ کامل، نه یک تعریف تنها.", 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 f27abeaa186e..91146e0fc0b9 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", @@ -999,6 +1001,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", @@ -2138,11 +2141,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", @@ -2332,8 +2337,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}}", @@ -2585,6 +2592,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", @@ -2760,6 +2770,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", @@ -2944,6 +2955,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", @@ -3264,6 +3276,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", @@ -3580,6 +3593,7 @@ "unprocessed": "Não processado", "unreadable-event": "Evento ilegível", "unrelated": "Não Relacionado", + "unresolved": "Unresolved", "unsaved": "Não salvo", "unsaved-changes": "Alterações não salvas", "unsupported": "Não suportado", @@ -5312,6 +5326,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "A consistência lógica desta versão não foi verificada.", "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", "ontology-format-csv-description": "Ida e volta em massa para termos e relações", @@ -5319,10 +5335,12 @@ "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-library-description": "Ontologias prontas e alinhadas a padrões. Instale como estão ou escolha os módulos primeiro.", + "ontology-iri-per-line": "Um IRI por linha; a ordem da cadeia de propriedades é preservada.", "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.", "ontology-pack-dry-run-success": "Simulação concluída", @@ -5344,6 +5362,8 @@ "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-studio-description": "Modele conceitos de negócio e como eles se relacionam.", + "ontology-remove-reused-name": "Remova primeiro o conceito salvo para atribuir outro conceito com o mesmo nome.", + "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-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.", "ontology-unlocks-feature-description": "Siga qualquer termo até seus pais, partes e dependências: contexto completo, não uma definição isolada.", 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 ab7925250e4b..1617efa19781 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", @@ -999,6 +1001,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", @@ -2138,11 +2141,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", @@ -2332,8 +2337,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}}", @@ -2585,6 +2592,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", @@ -2760,6 +2770,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", @@ -2944,6 +2955,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", @@ -3264,6 +3276,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", @@ -3580,6 +3593,7 @@ "unprocessed": "Não Processado", "unreadable-event": "Evento ilegível", "unrelated": "Não Relacionado", + "unresolved": "Unresolved", "unsaved": "Não guardado", "unsaved-changes": "Alterações não guardadas", "unsupported": "Não suportado", @@ -5312,6 +5326,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "A consistência lógica desta versão não foi verificada.", "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", "ontology-format-csv-description": "Ida e volta em massa para termos e relações", @@ -5319,10 +5335,12 @@ "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-library-description": "Ontologias pré-construídas e alinhadas com normas. Instale tal como estão ou escolha primeiro os módulos.", + "ontology-iri-per-line": "Um IRI por linha; a ordem da cadeia de propriedades é preservada.", "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.", "ontology-pack-dry-run-success": "Execução de teste concluída", @@ -5344,6 +5362,8 @@ "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-studio-description": "Modele conceitos de negócio e a forma como se relacionam.", + "ontology-remove-reused-name": "Remova primeiro o conceito guardado para atribuir outro conceito com o mesmo nome.", + "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-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.", "ontology-unlocks-feature-description": "Siga qualquer termo até aos seus ascendentes, partes e dependências: contexto completo, não uma definição isolada.", 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 d00e6c27045a..25cb3add197b 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": "Каскадно", @@ -999,6 +1001,7 @@ "disabled-application-plural": "Отключённые приложения", "discard": "Отклонить", "discover": "Обнаружить", + "disjoint-properties": "Непересекающиеся свойства", "dismiss": "Отклонить", "display-name": "Отображаемое имя", "display-name-lowercase": "отображаемое имя", @@ -2138,11 +2141,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": "Нативный", @@ -2332,8 +2337,10 @@ "ontology-explorer": "Обозреватель онтологии", "ontology-health": "Состояние онтологии", "ontology-inferred": "Онтология · выведено", + "ontology-iri": "IRI онтологии", "ontology-library": "Библиотека онтологий", "ontology-relation-plural": "Связи ontology", + "ontology-release": "Выпуск онтологии", "ontology-studio": "Студия онтологии", "open": "Открыто", "open-entity": "Открыть {{entity}}", @@ -2585,6 +2592,9 @@ "propagated": "Распространяется", "properties": "Свойства", "property": "Свойство", + "property-chain": "Цепочка свойств", + "property-domain": "Классы области определения", + "property-iri": "IRI свойства", "property-plural": "Properties", "propose-term": "Предложить термин", "proposed-by": "Предложено", @@ -2760,6 +2770,7 @@ "reposition": "Изменить положение", "repository-url": "URL Репозитория", "request": "Запрос", + "request-approval": "Запросить одобрение", "request-created-by": "Запрос создан", "request-data-access": "Запросить доступ к данным", "request-method": "Метод запроса", @@ -2944,6 +2955,7 @@ "schema-text": "Текст схемы", "schema-type": "Тип схемы", "schema-validation": "Проверка схемы", + "scheme-iri": "IRI схемы понятий", "scim-token": "Токен SCIM", "scope": "Область", "scope-and-behaviour": "Область видимости и поведение", @@ -3264,6 +3276,7 @@ "submit-review": "Отправить решение", "submit-review-count": "Отправить решение ({{count}})", "submitted-by": "Отправлено", + "submitted-definition": "Отправленное определение", "submitted-on": "Дата отправки", "submitting": "Отправка", "subscription": "Подписка", @@ -3580,6 +3593,7 @@ "unprocessed": "Необработанный", "unreadable-event": "Нечитаемое событие", "unrelated": "Не связан", + "unresolved": "Unresolved", "unsaved": "Не сохранено", "unsaved-changes": "Несохранённые изменения", "unsupported": "Не поддерживается", @@ -5312,6 +5326,8 @@ "ontology-ai-select-glossary": "Выберите один глоссарий, чтобы ограничить область предложений ИИ и авторизации.", "ontology-ai-standards-placeholder": "Введите стандарты через запятую, например FIBO, FHIR, schema.org", "ontology-edit-held-by": "{{holder}} редактирует этот глоссарий в другом сеансе. Закройте тот сеанс или подождите минуту, пока он истечёт, и повторите попытку.", + "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "Логическая согласованность этого выпуска не проверена.", "ontology-empty-description": "Онтология фиксирует связи между вашими терминами — что к чему относится, что из чего происходит — так что ваш словарь становится связанной моделью вашего бизнеса, а не просто алфавитным списком.", "ontology-empty-title": "Превратите свой глоссарий в карту связей между понятиями", "ontology-format-csv-description": "Массовый обмен терминами и связями", @@ -5319,10 +5335,12 @@ "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-library-description": "Готовые онтологии, соответствующие стандартам. Установите как есть или сначала выберите модули.", + "ontology-iri-per-line": "Один IRI в строке; порядок цепочки свойств сохраняется.", "ontology-library-load-error": "Не удалось загрузить библиотеку онтологий.", "ontology-not-connected-description": "У этих терминов пока нет связей. Свяжите их, чтобы разместить в онтологии.", "ontology-pack-dry-run-success": "Пробный запуск завершён", @@ -5344,6 +5362,8 @@ "ontology-relation-subtype": "{{from}} — это {{to}}", "ontology-relationships-feature-description": "Связывайте термины по смыслу: «Связано с», «Часть чего-то» или собственными типами, например «вычисляется из».", "ontology-studio-description": "Моделируйте бизнес-понятия и связи между ними.", + "ontology-remove-reused-name": "Сначала удалите сохранённое понятие, чтобы назначить другое понятие с тем же именем.", + "ontology-review-unavailable": "Не удалось загрузить отправленную версию. Возможно, она недоступна или у вас нет разрешения на её чтение.", "ontology-studio-edit-coming-soon": "Визуальное редактирование связей скоро появится. Пока изучайте граф онтологии или выполняйте SPARQL-запросы.", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", "ontology-unlocks-feature-description": "Проследите любой термин до его родителей, частей и зависимостей — полный контекст, а не отдельное определение.", 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 12ba36df6b8c..f560df555664 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", @@ -999,6 +1001,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", @@ -2138,11 +2141,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", @@ -2332,8 +2337,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}}", @@ -2585,6 +2592,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", @@ -2760,6 +2770,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", @@ -2944,6 +2955,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", @@ -3264,6 +3276,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", @@ -3580,6 +3593,7 @@ "unprocessed": "Obearbetad", "unreadable-event": "Oläsbar händelse", "unrelated": "Orelaterad", + "unresolved": "Unresolved", "unsaved": "Osparad", "unsaved-changes": "Osparade ändringar", "unsupported": "Stöds inte", @@ -5312,6 +5326,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "Den logiska konsistensen för denna version har inte kontrollerats.", "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", "ontology-format-csv-description": "Bulk-tur och retur för termer och relationer", @@ -5319,10 +5335,12 @@ "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-library-description": "Färdiga, standardanpassade ontologier. Installera som de är, eller välj moduler först.", + "ontology-iri-per-line": "En IRI per rad; egenskapskedjans ordning bevaras.", "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.", "ontology-pack-dry-run-success": "Testkörningen är klar", @@ -5344,6 +5362,8 @@ "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-studio-description": "Modellera affärsbegrepp och hur de hänger ihop.", + "ontology-remove-reused-name": "Ta först bort det sparade begreppet för att tilldela ett annat begrepp med samma namn.", + "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-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.", "ontology-unlocks-feature-description": "Följ vilken term som helst till dess överordnade, delar och beroenden – full kontext, inte en ensam definition.", 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 767243eb1c90..ba010abe6047 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", @@ -999,6 +1001,7 @@ "disabled-application-plural": "แอปพลิเคชันที่ปิดใช้งาน", "discard": "ทิ้ง", "discover": "ค้นพบ", + "disjoint-properties": "คุณสมบัติที่ไม่ทับซ้อนกัน", "dismiss": "ละทิ้ง", "display-name": "ชื่อที่แสดง", "display-name-lowercase": "ชื่อแสดง", @@ -2138,11 +2141,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": "เนทีฟ", @@ -2332,8 +2337,10 @@ "ontology-explorer": "ตัวสำรวจออนโทโลยี", "ontology-health": "สถานะออนโทโลจี", "ontology-inferred": "ออนโทโลยี · อนุมาน", + "ontology-iri": "IRI ของออนโทโลยี", "ontology-library": "คลังออนโทโลยี", "ontology-relation-plural": "ความสัมพันธ์ของ Ontology", + "ontology-release": "รุ่นออนโทโลยี", "ontology-studio": "สตูดิโอออนโทโลยี", "open": "เปิด", "open-entity": "เปิด {{entity}}", @@ -2585,6 +2592,9 @@ "propagated": "กระจาย", "properties": "คุณสมบัติ", "property": "คุณสมบัติ", + "property-chain": "สายโซ่คุณสมบัติ", + "property-domain": "คลาสโดเมน", + "property-iri": "IRI ของคุณสมบัติ", "property-plural": "Properties", "propose-term": "เสนอคำศัพท์", "proposed-by": "เสนอโดย", @@ -2760,6 +2770,7 @@ "reposition": "ปรับตำแหน่งใหม่", "repository-url": "URL ที่เก็บข้อมูล", "request": "คำขอ", + "request-approval": "ขออนุมัติ", "request-created-by": "คำขอสร้างโดย", "request-data-access": "ขอการเข้าถึงข้อมูล", "request-method": "วิธีการของคำขอ", @@ -2944,6 +2955,7 @@ "schema-text": "ข้อความสคีมา", "schema-type": "ประเภทสคีมา", "schema-validation": "การตรวจสอบสคีมา", + "scheme-iri": "IRI ของแบบแผนแนวคิด", "scim-token": "โทเค็น SCIM", "scope": "ขอบเขต", "scope-and-behaviour": "ขอบเขตและพฤติกรรม", @@ -3264,6 +3276,7 @@ "submit-review": "ส่งผลการตรวจสอบ", "submit-review-count": "ส่งผลการตรวจสอบ ({{count}})", "submitted-by": "ส่งโดย", + "submitted-definition": "คำจำกัดความที่ส่ง", "submitted-on": "ส่งเมื่อ", "submitting": "กำลังส่ง", "subscription": "การสมัครสมาชิก", @@ -3580,6 +3593,7 @@ "unprocessed": "ยังไม่ได้ประมวลผล", "unreadable-event": "เหตุการณ์ที่อ่านไม่ได้", "unrelated": "ไม่เกี่ยวข้อง", + "unresolved": "Unresolved", "unsaved": "ยังไม่ได้บันทึก", "unsaved-changes": "การเปลี่ยนแปลงที่ยังไม่บันทึก", "unsupported": "ไม่รองรับ", @@ -5312,6 +5326,8 @@ "ontology-ai-select-glossary": "เลือกอภิธานศัพท์หนึ่งรายการเพื่อกำหนดขอบเขตข้อเสนอ AI และการอนุญาต", "ontology-ai-standards-placeholder": "ป้อนมาตรฐานโดยคั่นด้วยจุลภาค เช่น FIBO, FHIR, schema.org", "ontology-edit-held-by": "{{holder}} กำลังแก้ไขอภิธานศัพท์นี้ในเซสชันอื่น ปิดเซสชันนั้นหรือรอหนึ่งนาทีให้หมดอายุ แล้วลองอีกครั้ง", + "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "ยังไม่ได้ตรวจสอบความสอดคล้องเชิงตรรกะของรุ่นนี้", "ontology-empty-description": "ออนโทโลยีจับความสัมพันธ์ระหว่างคำศัพท์ของคุณ — อะไรเป็นของอะไร อะไรมาจากอะไร — เพื่อให้คำศัพท์ของคุณกลายเป็นแบบจำลองที่เชื่อมโยงกันของธุรกิจ ไม่ใช่แค่รายการเรียงตามตัวอักษร", "ontology-empty-title": "เปลี่ยนอภิธานศัพท์ของคุณให้เป็นแผนที่ว่าแนวคิดเชื่อมโยงกันอย่างไร", "ontology-format-csv-description": "การรับส่งข้อมูลจำนวนมากสำหรับคำศัพท์และความสัมพันธ์", @@ -5319,10 +5335,12 @@ "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-library-description": "ออนโทโลยีสำเร็จรูปที่สอดคล้องกับมาตรฐาน ติดตั้งได้ทันที หรือเลือกโมดูลก่อน", + "ontology-iri-per-line": "หนึ่ง IRI ต่อบรรทัด โดยคงลำดับของสายโซ่คุณสมบัติไว้", "ontology-library-load-error": "ไม่สามารถโหลดคลังออนโทโลยีได้", "ontology-not-connected-description": "คำเหล่านี้ยังไม่มีความสัมพันธ์ เชื่อมต่อเพื่อจัดวางในออนโทโลยี", "ontology-pack-dry-run-success": "ทดลองทำงานเสร็จสมบูรณ์", @@ -5344,6 +5362,8 @@ "ontology-relation-subtype": "{{from}} เป็น {{to}}", "ontology-relationships-feature-description": "เชื่อมโยงคำศัพท์ด้วยความหมาย: \"เกี่ยวข้องกับ\", \"เป็นส่วนหนึ่งของ\" หรือประเภทของคุณเองเช่น \"คำนวณจาก\"", "ontology-studio-description": "สร้างแบบจำลองแนวคิดทางธุรกิจและความสัมพันธ์ระหว่างกัน", + "ontology-remove-reused-name": "นำแนวคิดที่บันทึกไว้ออกก่อน เพื่อกำหนดแนวคิดอื่นที่มีชื่อเดียวกัน", + "ontology-review-unavailable": "ไม่สามารถโหลดเวอร์ชันที่ส่งได้ เวอร์ชันนี้อาจไม่พร้อมใช้งานหรือคุณอาจไม่มีสิทธิ์อ่าน", "ontology-studio-edit-coming-soon": "การสร้างความสัมพันธ์แบบภาพกำลังจะมาเร็ว ๆ นี้ ในระหว่างนี้ สำรวจกราฟออนโทโลยีหรือรันคำสั่ง SPARQL", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", "ontology-unlocks-feature-description": "ติดตามคำศัพท์ใด ๆ ไปยังรายการหลัก ส่วนประกอบ และการพึ่งพา — บริบทที่สมบูรณ์ ไม่ใช่คำนิยามที่โดดเดี่ยว", 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 b58934a866ba..e889618ac3f3 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", @@ -999,6 +1001,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", @@ -2138,11 +2141,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", @@ -2332,8 +2337,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ç", @@ -2585,6 +2592,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", @@ -2760,6 +2770,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", @@ -2944,6 +2955,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ış", @@ -3264,6 +3276,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", @@ -3580,6 +3593,7 @@ "unprocessed": "İşlenmemiş", "unreadable-event": "Okunamayan olay", "unrelated": "İlişkisiz", + "unresolved": "Unresolved", "unsaved": "Kaydedilmedi", "unsaved-changes": "Kaydedilmemiş değişiklikler", "unsupported": "Desteklenmiyor", @@ -5312,6 +5326,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "Bu sürümün mantıksal tutarlılığı kontrol edilmedi.", "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", "ontology-format-csv-description": "Terimler ve ilişkiler için toplu gidiş-dönüş", @@ -5319,10 +5335,12 @@ "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-library-description": "Hazır, standartlarla uyumlu ontolojiler. Olduğu gibi yükleyin veya önce modülleri seçin.", + "ontology-iri-per-line": "Her satıra bir IRI girin; özellik zincirinin sırası korunur.", "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.", "ontology-pack-dry-run-success": "Deneme çalıştırması tamamlandı", @@ -5344,6 +5362,8 @@ "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-studio-description": "İş kavramlarını ve aralarındaki ilişkileri modelleyin.", + "ontology-remove-reused-name": "Aynı ada sahip farklı bir kavram atamak için önce kayıtlı kavramı kaldırın.", + "ontology-review-unavailable": "Gönderilen sürüm yüklenemedi. Kullanılamıyor olabilir veya okuma izniniz olmayabilir.", "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.", "ontology-unlocks-feature-description": "Herhangi bir terimi üst öğelerine, parçalarına ve bağımlılıklarına kadar izleyin; tek başına bir tanım değil, tam bağlam.", 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 38d71df9f49f..236a9b7f2273 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", @@ -999,6 +1001,7 @@ "disabled-application-plural": "已禁用的应用", "discard": "丢弃", "discover": "发现", + "disjoint-properties": "互斥属性", "dismiss": "忽略", "display-name": "显示名称", "display-name-lowercase": "显示名称", @@ -2138,11 +2141,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": "原生", @@ -2332,8 +2337,10 @@ "ontology-explorer": "本体浏览器", "ontology-health": "本体健康状态", "ontology-inferred": "本体 · 推断", + "ontology-iri": "本体 IRI", "ontology-library": "本体资源库", "ontology-relation-plural": "Ontology 关系", + "ontology-release": "本体发布版本", "ontology-studio": "本体工作室", "open": "打开", "open-entity": "打开{{entity}}", @@ -2585,6 +2592,9 @@ "propagated": "传播", "properties": "属性", "property": "属性", + "property-chain": "属性链", + "property-domain": "定义域类", + "property-iri": "属性 IRI", "property-plural": "Properties", "propose-term": "提议术语", "proposed-by": "提议者", @@ -2760,6 +2770,7 @@ "reposition": "重新定位", "repository-url": "仓库URL", "request": "请求", + "request-approval": "请求审批", "request-created-by": "请求创建者", "request-data-access": "请求数据访问", "request-method": "请求方式", @@ -2944,6 +2955,7 @@ "schema-text": "数据模式文本", "schema-type": "架构类型", "schema-validation": "架构验证", + "scheme-iri": "概念体系 IRI", "scim-token": "SCIM 令牌", "scope": "范围", "scope-and-behaviour": "范围与行为", @@ -3264,6 +3276,7 @@ "submit-review": "提交审核", "submit-review-count": "提交审核 ({{count}})", "submitted-by": "提交者", + "submitted-definition": "已提交的定义", "submitted-on": "提交时间", "submitting": "提交中", "subscription": "订阅", @@ -3580,6 +3593,7 @@ "unprocessed": "未加工的", "unreadable-event": "无法读取的事件", "unrelated": "无关联", + "unresolved": "Unresolved", "unsaved": "未保存", "unsaved-changes": "未保存的更改", "unsupported": "不支持", @@ -5312,6 +5326,8 @@ "ontology-ai-select-glossary": "选择一个术语表,以限定 AI 建议和授权的范围。", "ontology-ai-standards-placeholder": "输入以逗号分隔的标准,例如 FIBO、FHIR、schema.org", "ontology-edit-held-by": "{{holder}} 正在另一个会话中编辑此术语表。请关闭该会话或等待一分钟使其过期,然后重试。", + "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "尚未检查此发布版本的逻辑一致性。", "ontology-empty-description": "本体捕获术语之间的关系——什么属于什么、什么派生自什么——使您的词汇成为业务的关联模型,而不仅仅是按字母排序的列表。", "ontology-empty-title": "将您的术语表转化为概念如何关联的地图", "ontology-format-csv-description": "术语与关系的批量往返", @@ -5319,10 +5335,12 @@ "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-library-description": "预构建、符合标准的本体。可直接安装,或先选择模块。", + "ontology-iri-per-line": "每行一个 IRI;属性链的顺序将被保留。", "ontology-library-load-error": "无法加载本体资源库。", "ontology-not-connected-description": "这些术语还没有任何关系。连接它们以将其放入本体中。", "ontology-pack-dry-run-success": "试运行已完成", @@ -5344,6 +5362,8 @@ "ontology-relation-subtype": "{{from}} 是一种 {{to}}", "ontology-relationships-feature-description": "用含义关联术语:“相关于”“属于”,或您自定义的类型,例如“计算自”。", "ontology-studio-description": "为业务概念及其相互关系建模。", + "ontology-remove-reused-name": "请先移除已保存的概念,再分配同名的其他概念。", + "ontology-review-unavailable": "无法加载已提交的版本。该版本可能不可用,或您没有读取权限。", "ontology-studio-edit-coming-soon": "关系可视化编辑即将推出。目前,您可以浏览本体图谱或运行 SPARQL 查询。", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", "ontology-unlocks-feature-description": "沿着任意术语追溯其父级、组成部分和依赖关系——获得完整的上下文,而非孤立的定义。", 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 352a2028ceee..63330fb43ece 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", @@ -999,6 +1001,7 @@ "disabled-application-plural": "已停用的應用程式", "discard": "放棄", "discover": "探索", + "disjoint-properties": "互斥屬性", "dismiss": "忽略", "display-name": "顯示名稱", "display-name-lowercase": "顯示名稱", @@ -2138,11 +2141,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": "原生", @@ -2332,8 +2337,10 @@ "ontology-explorer": "本體瀏覽器", "ontology-health": "本體健康狀態", "ontology-inferred": "本體 · 推斷", + "ontology-iri": "本體 IRI", "ontology-library": "本體資源庫", "ontology-relation-plural": "Ontology 關係", + "ontology-release": "本體發佈版本", "ontology-studio": "本體工作室", "open": "開啟", "open-entity": "開啟{{entity}}", @@ -2585,6 +2592,9 @@ "propagated": "已傳播", "properties": "屬性", "property": "屬性", + "property-chain": "屬性鏈", + "property-domain": "定義域類別", + "property-iri": "屬性 IRI", "property-plural": "Properties", "propose-term": "提議術語", "proposed-by": "提議者", @@ -2760,6 +2770,7 @@ "reposition": "重新定位", "repository-url": "仓库URL", "request": "請求", + "request-approval": "請求核准", "request-created-by": "Request created by", "request-data-access": "請求資料存取", "request-method": "請求方法", @@ -2944,6 +2955,7 @@ "schema-text": "結構文字", "schema-type": "結構類型", "schema-validation": "結構驗證", + "scheme-iri": "概念體系 IRI", "scim-token": "SCIM Token", "scope": "範圍", "scope-and-behaviour": "範圍與行為", @@ -3264,6 +3276,7 @@ "submit-review": "提交審核", "submit-review-count": "提交審核 ({{count}})", "submitted-by": "提交者", + "submitted-definition": "已提交的定義", "submitted-on": "提交時間", "submitting": "提交中", "subscription": "訂閱", @@ -3580,6 +3593,7 @@ "unprocessed": "未處理", "unreadable-event": "無法讀取的事件", "unrelated": "無關聯", + "unresolved": "Unresolved", "unsaved": "未儲存", "unsaved-changes": "未儲存的變更", "unsupported": "不支援", @@ -5312,6 +5326,8 @@ "ontology-ai-select-glossary": "選取一個詞彙表,以界定 AI 建議和授權的範圍。", "ontology-ai-standards-placeholder": "輸入以逗號分隔的標準,例如 FIBO、FHIR、schema.org", "ontology-edit-held-by": "{{holder}} 正在另一個工作階段中編輯此詞彙表。請關閉該工作階段或等待一分鐘使其過期,然後再試一次。", + "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", + "ontology-consistency-not-run": "尚未檢查此發佈版本的邏輯一致性。", "ontology-empty-description": "本體論擷取詞彙之間的關係——什麼屬於什麼、什麼衍生自什麼——使您的詞彙成為業務的連結模型,而不僅是按字母排序的清單。", "ontology-empty-title": "將您的詞彙表轉化為概念如何連結的地圖", "ontology-format-csv-description": "詞彙與關聯的大量往返", @@ -5319,10 +5335,12 @@ "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-library-description": "預先建置、符合標準的本體。可直接安裝,或先選擇模組。", + "ontology-iri-per-line": "每行一個 IRI;屬性鏈的順序將予以保留。", "ontology-library-load-error": "無法載入本體資源庫。", "ontology-not-connected-description": "這些術語尚未有任何關係。連接它們以將其放入本體中。", "ontology-pack-dry-run-success": "試執行已完成", @@ -5344,6 +5362,8 @@ "ontology-relation-subtype": "{{from}} 是一種 {{to}}", "ontology-relationships-feature-description": "用含義連結詞彙:「相關於」「屬於」,或您自訂的類型,例如「計算自」。", "ontology-studio-description": "為業務概念及其相互關係建模。", + "ontology-remove-reused-name": "請先移除已儲存的概念,再指派同名的其他概念。", + "ontology-review-unavailable": "無法載入已提交的版本。該版本可能無法使用,或您沒有讀取權限。", "ontology-studio-edit-coming-soon": "關係視覺化編輯即將推出。目前,您可以瀏覽本體圖譜或執行 SPARQL 查詢。", "ontology-tree-description": "Concepts across the selected glossaries, organized by hierarchy. Isolated and polyhierarchic terms are flagged.", "ontology-unlocks-feature-description": "沿著任一詞彙追溯其父項、組成部分和相依性——取得完整的脈絡,而非孤立的定義。", 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..b46b1fb31b43 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,263 @@ const ONTOLOGY_REASONING_PATH = '/ontology/reasoning'; const ONTOLOGY_STRUCTURE_PATH = '/ontology/structure'; const ONTOLOGY_SUBSETS_PATH = '/ontology/subsets'; +export const createOntology = async (request: CreateOntology) => { + const response = await APIClient.post(ONTOLOGIES_PATH, request); + + 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': 'application/json-patch+json' } } + ); + + 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': 'application/json-patch+json' } } + ); + + 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 +443,12 @@ export const deleteRelationshipType = async (id: string) => { }; export const listOntologyAxioms = async ( - glossary: string, + ontologyId: string, params?: ListParams ) => { const response = await APIClient.get>( ONTOLOGY_AXIOMS_PATH, - { params: { ...params, glossary } } + { params: { ...params, ontologyId } } ); return response.data; 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..170c7fe71586 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,7 +98,7 @@ export const getEntityTasksPath = ( }; export const getTaskDetailPathFromTask = (task: TaskEntity) => - getEntityTasksPath( + isOntologyReviewTask(task) ? getTaskDetailPath(task) : getEntityTasksPath( (task.about?.type as EntityType) ?? '', task.about?.fullyQualifiedName ?? '' ); 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, + }; +}; From 492e227c169704ef4d3fd1406420e78766703110 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 16 Oct 2026 17:09:03 -0700 Subject: [PATCH 02/10] Integrate native ontologies with Studio and memory review Preserve the merged glossary Studio and add native model, release and query surfaces. Apply native memory proposals atomically through the existing change set review workflow and per-ontology working-state lock. Seal canonical release content independently of audit and permission fields, bind approval to immutable content, recover transient review failures, and compare releases with their captured baseline. Keep native working edits out of field-level approval staging and enforce permissions on active-release queries. Keep unreleased ontology DDL in native/2.1.0, document the capability boundary, and remove unrelated SDK and event changes. Validate the native APIs on MySQL and PostgreSQL, plus active graph behavior against real Fuseki. --- .../native/2.1.0/mysql/schemaChanges.sql | 16 +- .../native/2.1.0/postgres/schemaChanges.sql | 8 +- ...nitions-have-independent-entity-storage.md | 50 +- ...eviews-bind-workflow-tasks-to-revisions.md | 22 +- ...tologies-share-studio-and-memory-review.md | 44 ++ ...26-10-14-organizational-ontology-design.md | 83 ++- ...10-16-native-ontology-studio-completion.md | 14 + .../tests/unit/models/test_ontology_iris.py | 3 - .../it/tests/AnnouncementResourceIT.java | 16 +- .../it/tests/AutoCloseIncidentIT.java | 53 +- .../openmetadata/it/tests/BaseOntologyIT.java | 14 + .../IndependentOntologyActivationIT.java | 61 +++ .../tests/IndependentOntologyApprovalIT.java | 6 +- .../it/tests/IndependentOntologyAxiomIT.java | 7 +- .../tests/IndependentOntologyHierarchyIT.java | 10 +- .../IndependentOntologyReleaseDiffIT.java | 10 + .../IndependentOntologyReleaseExportIT.java | 9 +- .../tests/IndependentOntologyReleaseIT.java | 4 + .../tests/IndependentOntologyResourceIT.java | 27 - .../tests/IndependentOntologyRetentionIT.java | 14 +- .../IndependentOntologyReviewRecoveryIT.java | 11 +- .../it/tests/NativeOntologyMemoryIT.java | 255 ++++++++++ .../it/tests/OntologyPropertyResourceIT.java | 36 +- .../it/tests/RdfFusekiAuthorizationIT.java | 30 +- .../openmetadata/it/tests/TeamResourceIT.java | 16 +- .../it/tests/alerts/BulkChangeEventIT.java | 23 +- .../alerts/EventSubscriptionResourceIT.java | 11 +- .../sdk/services/EntityServiceBase.java | 4 +- .../service/OpenMetadataApplication.java | 2 +- .../service/ResourceRegistry.java | 1 - .../governance/approval/ApprovalGate.java | 15 +- .../workflows/OntologyReviewReconciler.java | 5 +- .../jdbi3/ContextMemoryRepository.java | 13 +- .../service/jdbi3/EntityRepository.java | 61 +-- .../jdbi3/OntologyChangeSetRepository.java | 53 +- .../jdbi3/OntologyEntityRepository.java | 24 +- .../jdbi3/OntologyReleaseRepository.java | 8 +- .../service/jdbi3/OntologyRepository.java | 31 +- .../service/jdbi3/TaskRepository.java | 18 +- .../NativeOntologyChangeExecutor.java | 103 ++++ .../ontology/NativeOntologyChanges.java | 132 +++++ .../OntologyChangeApplicationService.java | 50 +- .../OntologyChangeOperationExecutor.java | 31 +- .../ontology/OntologyChangePreflight.java | 19 +- .../ontology/OntologyChangeSetValidator.java | 17 +- .../ontology/OntologyReleaseCapture.java | 3 +- .../ontology/OntologyReleaseContent.java | 58 +++ .../ontology/OntologyReleaseManifest.java | 3 +- .../ontology/OntologyReleaseQuery.java | 47 ++ .../ontology/OntologyReleaseReader.java | 6 +- .../service/ontology/OntologyReview.java | 42 +- .../rdf/OntologySparqlQueryService.java | 4 +- .../ontology/OntologyAiResource.java | 5 + .../ontology/OntologyChangeSetMapper.java | 10 +- .../ontology/OntologyChangeSetResource.java | 51 +- .../resources/ontology/OntologyMapper.java | 1 - .../ontology/OntologyReleaseResource.java | 9 +- .../resources/ontology/OntologyResource.java | 60 +++ .../ontology/OntologyResourceAccess.java | 15 + .../service/resources/tasks/TaskResource.java | 2 +- .../service/security/DefaultAuthorizer.java | 6 +- .../service/tasks/TaskWorkflowHandler.java | 3 + .../tasks/TaskWorkflowLifecycleResolver.java | 5 +- .../OntologyApprovalWorkflowTest.java | 4 +- .../OntologyWorkflowDecisionTest.java | 4 +- .../jdbi3/EntityRepositoryBulkEventTest.java | 72 --- .../jdbi3/EntityRepositoryRestoreTest.java | 1 + .../service/jdbi3/OntologyEntityDAOTest.java | 34 +- .../service/jdbi3/OntologyTestDatabase.java | 2 +- .../v210/FlowableCharsetMigrationTest.java | 6 +- .../ontology/NativeOntologyChangeSetTest.java | 141 ++++++ .../ontology/OntologyReleaseReaderTest.java | 61 +++ .../service/ontology/OntologyReviewTest.java | 38 +- .../resources/rdf/projection-entities.json | 8 +- .../json/schema/api/data/createOntology.json | 3 - .../schema/api/data/createOntologyAxiom.json | 3 - .../api/data/createOntologyChangeSet.json | 21 +- .../api/data/createOntologyConcept.json | 3 - .../api/data/createOntologyProperty.json | 3 - .../json/schema/entity/data/ontology.json | 9 - .../schema/entity/data/ontologyAxiom.json | 3 - .../schema/entity/data/ontologyChangeSet.json | 23 +- .../schema/entity/data/ontologyConcept.json | 3 - .../schema/entity/data/ontologyProperty.json | 3 - .../schema/entity/data/ontologyRelease.json | 8 +- .../accessControl/resourceDescriptor.json | 1 - .../schema/type/ontologyChangeOperation.json | 27 +- .../schema/type/ontologyReviewTarget.json | 20 +- .../resources/rdf/ontology/openmetadata.ttl | 20 +- .../e2e/Features/OntologyReview.spec.ts | 208 +++++++- .../TaskTab/TaskTabNew.component.test.tsx | 3 - .../OntologyAxiomPanel.test.tsx | 98 +--- .../OntologyExplorer/OntologyAxiomPanel.tsx | 413 +-------------- .../OntologyMemoryReviewPanel.test.tsx | 52 ++ .../OntologyMemoryReviewPanel.tsx | 131 ++++- .../OntologyModelingWorkbench.tsx | 2 +- .../OntologyModelingWorkbench.utils.test.ts | 2 +- .../OntologyModelingWorkbench.utils.ts | 6 +- .../OntologyStudioQueryConsole.test.tsx | 34 ++ .../OntologyStudioQueryConsole.tsx | 21 +- .../components/TaskDetailPanel.test.tsx | 3 - .../OntologyDefinitionList.tsx | 48 +- .../OntologyAxiomEditor.test.tsx | 107 ++++ .../OntologyAxiomEditor.tsx | 472 ++++++++++++++++++ .../OntologyAxiomWorkspace.tsx | 22 +- .../OntologyCreateForm.test.tsx | 51 ++ .../OntologyCreateForm/OntologyCreateForm.tsx | 38 +- .../OntologyCreateForm.utils.ts | 53 +- .../OntologyGraphView/OntologyGraphView.tsx | 8 +- .../OntologyReleaseDetails.tsx | 6 +- .../OntologyReviewPanel.test.tsx | 3 - .../OntologyStudioWorkspace.tsx | 155 ++++++ .../resources/ui/src/enums/entity.enum.ts | 4 + .../src/generated/api/data/createOntology.ts | 23 +- .../generated/api/data/createOntologyAxiom.ts | 1 - .../api/data/createOntologyChangeSet.ts | 253 ++++++++-- .../api/data/createOntologyConcept.ts | 9 +- .../api/data/createOntologyProperty.ts | 1 - .../api/data/ontologyDomainDraftResult.ts | 253 ++++++++-- .../api/data/ontologyMappingSuggestion.ts | 239 +++++++-- .../api/data/ontologyMappingSuggestionList.ts | 239 +++++++-- .../data/ontologyRelationshipSuggestion.ts | 239 +++++++-- .../ontologyRelationshipSuggestionList.ts | 239 +++++++-- .../api/data/ontologyStructuralMergeResult.ts | 4 + .../api/data/updateOntologyChangeSet.ts | 239 +++++++-- .../generated/api/policies/createPolicy.ts | 1 - .../ui/src/generated/entity/data/ontology.ts | 28 +- .../generated/entity/data/ontologyAxiom.ts | 1 - .../entity/data/ontologyChangeSet.ts | 268 +++++++--- .../generated/entity/data/ontologyConcept.ts | 7 +- .../generated/entity/data/ontologyProperty.ts | 3 +- .../generated/entity/data/ontologyRelease.ts | 7 +- .../accessControl/resourceDescriptor.ts | 1 - .../accessControl/resourcePermission.ts | 1 - .../entity/policies/accessControl/rule.ts | 1 - .../src/generated/entity/policies/policy.ts | 1 - .../ui/src/generated/entity/tasks/task.ts | 10 +- .../generated/type/ontologyChangeOperation.ts | 239 +++++++-- .../generated/type/ontologyReleaseEvent.ts | 10 +- .../generated/type/ontologyReviewTarget.ts | 10 +- .../ui/src/locale/languages/ar-sa.json | 9 +- .../ui/src/locale/languages/de-de.json | 9 +- .../ui/src/locale/languages/en-us.json | 7 +- .../ui/src/locale/languages/es-es.json | 9 +- .../ui/src/locale/languages/fr-fr.json | 9 +- .../ui/src/locale/languages/gl-es.json | 9 +- .../ui/src/locale/languages/he-he.json | 9 +- .../ui/src/locale/languages/ja-jp.json | 9 +- .../ui/src/locale/languages/ko-kr.json | 9 +- .../ui/src/locale/languages/mr-in.json | 9 +- .../ui/src/locale/languages/nl-nl.json | 9 +- .../ui/src/locale/languages/pr-pr.json | 9 +- .../ui/src/locale/languages/pt-br.json | 9 +- .../ui/src/locale/languages/pt-pt.json | 9 +- .../ui/src/locale/languages/ru-ru.json | 9 +- .../ui/src/locale/languages/sv-se.json | 9 +- .../ui/src/locale/languages/th-th.json | 9 +- .../ui/src/locale/languages/tr-tr.json | 9 +- .../ui/src/locale/languages/zh-cn.json | 9 +- .../ui/src/locale/languages/zh-tw.json | 9 +- .../OntologyExplorerPage.tsx | 140 +++++- .../OntologyExplorerPage.utils.test.ts | 306 ------------ .../OntologyExplorerPage.utils.ts | 188 ------- .../main/resources/ui/src/rest/ontologyAPI.ts | 31 +- .../src/main/resources/ui/src/rest/rdfAPI.ts | 9 + .../resources/ui/src/utils/EntityNameUtils.ts | 4 + .../ui/src/utils/TaskNavigationUtils.ts | 10 +- 167 files changed, 5042 insertions(+), 2382 deletions(-) create mode 100644 docs/decisions/2026-10-16-native-ontologies-share-studio-and-memory-review.md create mode 100644 docs/plans/2026-10-16-native-ontology-studio-completion.md create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/NativeOntologyMemoryIT.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/NativeOntologyChangeExecutor.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/NativeOntologyChanges.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseContent.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseQuery.java delete mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryBulkEventTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/ontology/NativeOntologyChangeSetTest.java create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReleaseReaderTest.java create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomEditor/OntologyAxiomEditor.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyAxiomEditor/OntologyAxiomEditor.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/governance/ontology/OntologyStudioWorkspace/OntologyStudioWorkspace.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.utils.test.ts delete mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.utils.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 9276666528f5..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,10 +875,6 @@ PREPARE rdf_projection_health_watermark_stmt FROM @rdf_projection_health_waterma EXECUTE rdf_projection_health_watermark_stmt; DEALLOCATE PREPARE rdf_projection_health_watermark_stmt; --- 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; - -- 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, @@ -1080,7 +1076,11 @@ 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_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_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_CONTAINS(json, '{"operationType":"UPSERT_AXIOM"}', '$.operations') OR JSON_CONTAINS(json, '{"operationType":"DELETE_AXIOM"}', '$.operations')); -DELETE FROM ontology_change_set_entity WHERE JSON_CONTAINS(json, '{"operationType":"UPSERT_AXIOM"}', '$.operations') OR JSON_CONTAINS(json, '{"operationType":"DELETE_AXIOM"}', '$.operations'); + (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 67806af4a1d2..1d45d461c133 100644 --- a/bootstrap/sql/migrations/native/2.1.0/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/2.1.0/postgres/schemaChanges.sql @@ -842,7 +842,7 @@ 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->'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->'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->'operations' @> '[{"operationType":"UPSERT_AXIOM"}]'::jsonb OR json->'operations' @> '[{"operationType":"DELETE_AXIOM"}]'::jsonb); -DELETE FROM ontology_change_set_entity WHERE json->'operations' @> '[{"operationType":"UPSERT_AXIOM"}]'::jsonb OR json->'operations' @> '[{"operationType":"DELETE_AXIOM"}]'::jsonb; + (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 index 5a2f1742842d..59cc73f98382 100644 --- 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 @@ -83,14 +83,14 @@ 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 -change sets containing axiom operations. This is an explicit reset of development Studio axioms; +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 and nonempty import requests are rejected. +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 @@ -123,46 +123,6 @@ resolves a definition from an unrelated module. The working-reference API pages 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. -## Follow-up: metadata usage search - -Keep `tag_usage` authoritative for metadata annotations. Project the distinct UUIDs from native -ontology labels into the keyword field `ontologyConceptIds` in asset search documents. Include -annotated child fields in their parent asset's document and retain separate column documents when -column indexing is enabled. Never derive this field from another label source, even when it carries -the same FQN or metadata keys. Reindexing and live tag-update scripts produce the same projection. - -Reverse asset lists and counts use the existing search endpoint and its access controls. Query by -concept UUID rather than FQN, preserving unresolved assignments after working-definition deletion -or name reuse. The search projection is eventually consistent; SQL remains the assignment record. -Published concept pages authorize the sealed definition before showing associated assets. Search -failures have an explicit retry state and do not imply zero associations. Existing indexes require -the normal mapping update and reindex to populate the new field. `ParseTagsTest`, `TaggableIndexTest`, -`CustomIndexTest`, and `IndependentOntologyLabelsIT` guard this projection. - -Define the shared keyword field in `IndexMapping.readMapping`, used by index creation, updates, -rebuilds, mapping lookup and version fingerprints. Language-specific JSON files retain their existing -fields and analyzers; they do not repeat the ontology field. This field is independent of optional -search hardening settings. - -## Follow-up: metadata CSV annotations - -Append an optional `ontologyAnnotations` column to metadata CSV formats. Its value is a JSON array -of existing `TagLabel` objects carrying source `Ontology`, stable ontology/concept UUIDs, assignment -state, label type and provenance. Export omits derived display fields and the computed `resolved` -flag. Classification and glossary columns keep their existing meanings and positions. - -An omitted column preserves each target's current ontology annotations; an explicitly empty cell -removes them. Import validates supplied identities through `OntologyLabelResolver`, including in -dry runs, and persists through the existing repository/tag-usage mechanisms. The same codec serves -entity, nested-column, bulk-column and search-result exports. The bulk edit grid displays concept -names and suggestion state, retains the serialized annotations, and keeps this structured column -read-only. CSV files can replace the column explicitly; malformed or forged assignments fail the row. - -Define the annotation header once in `OntologyAnnotationCsv`, applied by the existing documentation -loader and CSV constructor to formats with a tag column. This also covers headers declared in Java. -Row builders use `CsvUtil.addOntologyAnnotations`, alongside the existing classification and glossary -helpers; the generic two-argument record writer stays unchanged. - ## Amendment: validation cost and hierarchy Store named subclass relationships only as ontology-owned `OntologyAxiom` rows. Concept reads @@ -176,9 +136,3 @@ through the reference index. These reads share the ontology state lock; MySQL re 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. - -In the metadata follow-up, annotations reuse the existing `tag_usage` store and shared tag writers. Batch writes -resolve identities and approved/active content together through existing chunked DAO queries. -Cached assignments refresh their meaning from the active release on read. A column datatype -change carries its existing annotation identity, assignment state and provenance forward, even -when its concept is no longer active; it does not authorize a new assignment to an unapproved concept. 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 index 13399ba5dcdc..afcf8b1f5edc 100644 --- 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 @@ -1,7 +1,7 @@ # Ontology releases use the existing approval workflow - **Status:** Accepted -- **Revisions:** v1 2026-10-14 (initial); v11 2026-10-15 (release-only decisions and mandatory review content) +- **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 @@ -16,8 +16,8 @@ An editable status or a governance-bot identity cannot establish approval. 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, content revision/digest, author, owning ontology UUID and workflow version/ -digest in the task's immutable `ontologyReview` field. Runtime assignments, vote thresholds and +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 @@ -26,11 +26,12 @@ before inserting a task and recheck under the ontology lock. An approved or reje 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-v2`. Exclude owners, reviewers, operational status/audit fields -and derived hierarchy fields. Current reviewer eligibility is checked on every vote, including earlier +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. Preserve the submitted workflow fingerprint as evidence, while executing -the actual runtime approval step rather than interpreting a new workflow definition mid-process. +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 @@ -38,7 +39,7 @@ alone cannot refresh a repeatable-read database snapshot. Cancellation takes ont 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 recovery closes the invalid review; current content cannot substitute +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 @@ -65,8 +66,9 @@ gets five minutes to start before cancellation. Existing workflows are not timed 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 cancel the pending review. Cancellation -terminates the workflow instance tree; its marker stays until execution cleanup finishes. Closed +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. 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/plans/2026-10-14-organizational-ontology-design.md b/docs/plans/2026-10-14-organizational-ontology-design.md index c340d6a55a25..623699f4fc46 100644 --- a/docs/plans/2026-10-14-organizational-ontology-design.md +++ b/docs/plans/2026-10-14-organizational-ontology-design.md @@ -1,7 +1,7 @@ # Governed organizational ontologies in Ontology Studio -**Status:** Reduced native scope implemented; focused validation completed. Metadata annotations are a separate follow-up. -**Decision date:** 2026-10-15. **Code baseline:** `d099f6f553b`. +**Status:** Native entities and publication implemented; main Studio restored. Native memory integration and merged validation in progress. Metadata annotations are a separate follow-up. +**Decision date:** 2026-10-16. **Merged main baseline:** `c2802106708`. ## Goal and scope @@ -47,7 +47,6 @@ is deduplicated across versions. | `ontology_release_entity` | Immutable release header and workflow-owned review state | Standard entity/history mechanism; captured `baseReleaseId` | | `ontology_release_member` | Frozen root, concept, property and axiom payloads with source version, digest and size | Canonical JSON text; `(releaseId, memberKind, memberId)` identity | | `ontology_release_event` | Capture, decision and activation evidence | Append-only events independent of Flowable history cleanup | -| `tag_usage` (follow-up) | Concept-to-metadata assignments | Existing tag writers; appended `Ontology` source and stable ontology/concept UUIDs | Concept, property and axiom listings use `(ontologyId, deleted, name, id)` indexes matching the existing cursor order. The existing `entity_extension` index serves review recovery; no new index @@ -64,7 +63,7 @@ cycles, subclass edges involving SKOS-only concepts and individual `sameAs` mapp ## Authoring, capture and validation Working definitions use the existing repository template, optimistic version checks, history and -inheritance of owners/reviewers. Their writes share the ontology state lock with capture. Semantic +inheritance of owners. Reviewers are configured on the ontology and inherited by its releases. Their writes share the ontology state lock with capture. Semantic writes read only changed definitions, affected dependents, referenced declarations and relevant subclass ancestors. Documentation and ownership edits do not reload the entire model. Invalid unrelated draft content must not prevent the edit that repairs it. @@ -99,11 +98,10 @@ keys and hashes UTF-8 bytes with SHA-256. Sealed members cannot be edited or app Cancellation can return an undecided candidate to Draft for a new task. There is no per-definition approval engine and no direct endpoint that grants approval. Working edits -cannot change a sealed review target. Owners/reviewers and derived fields are excluded from the review -digest; current reviewer eligibility is rechecked at voting time. +cannot change a sealed review target. The review digest binds only release UUID, release revision and sealed content digest; current reviewer eligibility is rechecked at voting time. The existing recovery marker in `entity_extension` bridges the Flowable/entity transaction boundary. -A typed receipt is recorded with the final engine vote and applied only after successful committed +Temporary authorization, missing-entity and policy errors remain pending for recovery rather than cancelling the review. A typed receipt is recorded with the final engine vote and applied only after successful committed process history. The reconciler uses fresh reads, bounded scans and consistent lock order. It recovers start/completion crashes and terminates cancelled workflows. Ontology-specific strict completion must not change glossary or metric approval contracts. Multi-vote glossary retry wraps the whole @@ -128,48 +126,46 @@ Retained releases prevent hard deletion of the owning ontology. Archive working retaining release headers, members, events and authorized export. Permanent release expiry/purge requires a later policy. Release exports and diagnostics work from SQL without a running RDF store. -## Linking concepts to catalog metadata - -Reuse `tag_usage` and `TagLabel`; append the `Ontology` source without changing classification or -glossary source ordinals. Store stable ontology/concept UUIDs alongside the existing assignment state, -label type, actor and timestamp. A new assignment must refer to a concept in an approved release. -Resolve displayed meaning from the active release, without falling back to a mutable draft. - -When the concept is absent from the active release or its ontology is archived, retain the assignment -as unresolved. Rollback can resolve it again. Reusing a working name cannot rebind its UUID. Cached -assignments refresh active meaning on read. Column datatype changes preserve existing annotations -and provenance, including unresolved links. They do not constitute new assignments. - -Use shared, chunked DAO reads for batch binding. Project `ontologyConceptIds` through the shared -search mapping loader and common tag projection, rather than copying a field into every language -mapping. Reverse asset lists use UUID filters and normal search access controls. - -Metadata CSV uses one optional `ontologyAnnotations` column and the shared tag codec. An omitted -column preserves annotations; an explicit empty cell clears them. This is metadata interchange, -separate from deferred native ontology-definition bulk/CSV authoring. - ## Studio and glossary compatibility -The Studio entry point uses native ontologies. One selected ontology drives graph/tree browsing, -concept/property/axiom authoring and release publishing. Reuse the existing graph and tree renderers -and controls. The metadata-label follow-up adds published-concept links and the asset view. -The query console starts with the selected ontology's active named graph. - -The focused native scope defers the glossary-based AI assistant, library, templates, import/subset/ -merge tools and native graph mutation shortcuts. Their native semantics and workflow integration -need a later slice. Preserve business-glossary configurable relationships and its Relations Graph; -do not treat a glossary as a native ontology or display its terms as native concepts. +Main's redesigned Studio remains the entry point. Glossaries retain AI assistance, Context Center +memory review (`?draft=`), edit leases, library, import/export, visual query builder, data-explore, +graph authoring, workbench panels and health/isolated controls. The glossary Relations Graph remains +available. A native ontology selector adds Graph, Model, Releases, Review and Query surfaces without +converting glossary terms into native definitions. The formal axiom editor selects a native ontology. -Browser coverage follows those boundaries: native authoring, hierarchy, review and publication replace -the former global glossary-Studio flows. Glossary graph, cardinality and cross-glossary checks run at -Relations Graph. Existing glossary RDF/API checks remain; browser checks for deferred global Studio -features are removed with those entry points. +| Capability | Native ontology support | Follow-up | +|---|---|---| +| Working definitions | Ontology/reviewer editing; concept/property/axiom create, edit and delete; derived hierarchy | Direct graph mutation shortcuts | +| Publishing | Capture, whole-release workflow review, baseline diff, activation and rollback | Consistency checking through Jena CHECK | +| Memory proposals | Typed concept/property/axiom change sets; source-memory links; existing review panel and change-set lease; atomic working-state application; next capture includes the changes | Automatic native AI proposal generation and native selection in Context Center's proposal action | +| Edit leases | Existing glossary and change-set leases retained; native memory review uses the same lease | Native ontology/definition editor leases; version guards and working-state lock protect concurrent writes now | +| Library | Existing glossary packs retained | Import a library pack into a native working model | +| Import/export | Authorized sealed native release export | Native import and bulk authoring | +| Query | Permission-checked queries over the SQL projection of the active release; no draft data or administrator requirement | Native visual query builder | +| Data explore | Existing glossary data-explore retained | Native concept-to-asset links, tracked in the separate metadata-labels patch | + +Native memory drafts have exactly one ontology scope and a `baseWorkingRevision`. They use the +existing change-set table and review state. Application locks the ontology, checks the revision and +target versions, applies ordinary repository operations, and validates the final model once. Any +failure rolls back definitions, references and the change-set transition. Applying a draft does not +publish it: a subsequent capture must still pass release review and activation. Source memories use +existing `DERIVED_FROM` relationship rows. Retired source memories cannot ground new applications. + +The native query endpoint builds the same asserted model as the Fuseki active graph from verified +sealed SQL members. This provides consistent publication semantics while asynchronous RDF projection +is pending or RDF is disabled. It authorizes the root and every member, retains SPARQL safety limits, +and rejects remote SERVICE queries. + +Metadata-to-concept annotations remain a separate follow-up using existing tag storage. This change +does not extend asset labels, CSV formats or search mappings. The metadata-labels follow-up patch +contains that design and implementation and is not applied to this branch. ## Review and delivery boundaries | Review unit | Includes | Depends on | |---|---|---| -| Shared fixes | Approval retry/transition correctness, SDK patch/error handling and entity lifecycle events | Existing platform | +| Shared fixes | Approval retry/transition correctness, ontology-specific workflow integration | Existing platform | | Native entities | Schemas, DAOs, native 2.1.0 DDL, reference index, working authoring and Studio data selection | Shared entity framework | | Publishing | Immutable captures, release-only workflow, comparison, activation/rollback and active RDF projection | Native entities | | Metadata annotations | `tag_usage` identity, catalog/field UI, shared search projection and CSV | Native entities and approved releases | @@ -196,7 +192,8 @@ Required scenarios: - Use one ontology selection across native Studio graph/tree, authors and publication. Exercise browser review/capture/activation and keep the glossary Relations Graph covered. -Focused local validation completed on 2026-10-15: +Historical validation before merging main, completed on 2026-10-15 (these results do not validate the +2026-10-16 merged implementation): | Check | Result | |---|---| @@ -223,7 +220,7 @@ production-readiness claim follows from these focused checks. Development databases created with the earlier unreleased per-definition/import release manifests must be reset for this contract. Native 2.1.0 explicitly resets glossary-owned development axioms, -their relationship/history rows, and development change sets containing axiom operations before +their relationship/history rows, and glossary-scoped development change sets containing axiom operations before adding the non-null ontology owner; it does not convert or delete business glossaries. Storage and recovery details: ADR:2026-10-14-ontology-definitions-have-independent-entity-storage, diff --git a/docs/plans/2026-10-16-native-ontology-studio-completion.md b/docs/plans/2026-10-16-native-ontology-studio-completion.md new file mode 100644 index 000000000000..43e5d36f577c --- /dev/null +++ b/docs/plans/2026-10-16-native-ontology-studio-completion.md @@ -0,0 +1,14 @@ +# Native ontology completion + +The approved scope preserves main's complete glossary Studio and adds native ontologies alongside it. Publication approves a sealed release through the existing workflow; editing a definition does not start another approval lifecycle. Metadata labels remain a separate follow-up. + +1. Merge `origin/main` at `c2802106708`, retaining its Studio components, page, existing browser specs and `TaskResourceIT`. Reconcile shared task hooks and regenerate locales. Verify conflict markers, formatting and compilation. +2. Fix release durability and review recovery in `service/ontology/OntologyReleaseCapture`, `OntologyReleaseReader`, `OntologyReview`, and `governance/workflows/OntologyReviewReconciler`. Default release comparisons to their captured base. Move ontology DDL before the final database collation change in native `2.1.0`. Verify release, review, migration and activation tests. +3. Extend the change-set schema and existing application service with a native ontology scope. Apply concept/property/axiom operations under `OntologyWorkingState`, preserve optimistic revisions and permissions, and expose those changes in the existing memory review panel. Record the contract in an ADR; regenerate Java, Python and TypeScript models. Verify atomic application, stale drafts, authorization and release capture. +4. Integrate native selection, Model, Releases and active-release Query into main's `OntologyExplorerPage`. Complete ontology/reviewer editing and definition/axiom CRUD in `components/governance/ontology`. Keep the existing glossary graph, AI, library, leases, import/export, query builder, data-explore and workbench. Verify Jest plus restored and native browser specs, including non-admin users. +5. Remove obsolete labels/imports/per-definition review remnants; isolate unrelated SDK, event and test changes. Update the design and capability matrix to distinguish native support from follow-ups. +6. Run affected Java tests and ITs on MySQL and PostgreSQL with real Fuseki, UI lint/type checks, focused Jest and browser tests. Record exact evidence and remaining limits in the session validation artifact. No push. + +Status: checkpoint `919aaaa9ec6` created; main merged in `25df2e9e919`. Native memory application, authorization, stale drafts, rollback, release capture, review, recovery, comparison and activation pass MySQL and PostgreSQL API tests. The 64 targeted service tests pass, including both database dialects. All four failures from the expanded MySQL run passed focused reruns, alongside 19 restore unit tests. + +PostgreSQL validation: the affected API/migration run passed 2,200 tests with 181 skips; the isolated run passed 334 with one embedding test skipped for missing Bedrock configuration. The RDF run passed 279 with one optional scale skip and two fixture-collision failures; isolating the replacement graph fixed those failures, and all 12 authorization cases passed the rerun. Native activation, rollback and rebuild passed with real Fuseki. All 22 shared SPARQL tests passed both alone and in the RDF lane after readiness failures in the initial mixed run. The search lane initially passed 68 tests with five errors: four aggregation-count errors shared an orphaned search fixture, reproduced by a minimal sequence on clean main; one user-count error compared non-deleted API users against unfiltered search documents. Both causes are fixed in two test files: cleanup removes the orphan's search document, and reconciliation includes soft-deleted entities on both sides with an explicit deleted-user fixture. All seven tests in the three affected classes passed together on PostgreSQL/OpenSearch, including all five prior errors; the full search selection was not repeated. Browser validation and the remaining affected-test lanes are outstanding. No completion or merge-readiness claim is made yet. diff --git a/ingestion/tests/unit/models/test_ontology_iris.py b/ingestion/tests/unit/models/test_ontology_iris.py index 07e099a64bdb..0c6a0c2c0366 100644 --- a/ingestion/tests/unit/models/test_ontology_iris.py +++ b/ingestion/tests/unit/models/test_ontology_iris.py @@ -56,11 +56,8 @@ def test_review_target_pins_the_submitted_entity_history_version(): "entityId": "00000000-0000-0000-0000-000000000001", "ontologyId": "00000000-0000-0000-0000-000000000002", "entityVersion": 0.4, - "reviewRevision": 3, "contentDigest": "a" * 64, "author": "author", - "workflowDefinitionVersion": 0.1, - "workflowDefinitionDigest": "b" * 64, } stored = OntologyReviewTarget.model_validate(payload).model_dump(mode="json", exclude_none=True) assert stored == payload diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AnnouncementResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AnnouncementResourceIT.java index 6c5dc8a77e34..925e73061e48 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AnnouncementResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AnnouncementResourceIT.java @@ -13,12 +13,7 @@ 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.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import com.fasterxml.jackson.databind.JsonNode; import java.util.List; @@ -52,6 +47,7 @@ import org.openmetadata.sdk.client.OpenMetadataClient; import org.openmetadata.sdk.exceptions.ForbiddenException; import org.openmetadata.sdk.exceptions.InvalidRequestException; +import org.openmetadata.sdk.exceptions.OpenMetadataException; import org.openmetadata.sdk.fluent.DatabaseSchemas; import org.openmetadata.sdk.fluent.Databases; import org.openmetadata.sdk.models.ListParams; @@ -569,9 +565,13 @@ void testPatchCannotExceedCustomTypeNameLength(TestNamespace ns) { created.setCustomTypeName("x".repeat(200)); - InvalidRequestException rejected = + // Asserted on the cause, not the thrown type: unlike create, the SDK's update() wraps every + // failure in a bare OpenMetadataException, so the typed 400 only survives underneath it. + OpenMetadataException error = assertThrows( - InvalidRequestException.class, () -> patchEntity(created.getId().toString(), created)); + OpenMetadataException.class, () -> patchEntity(created.getId().toString(), created)); + InvalidRequestException rejected = + assertInstanceOf(InvalidRequestException.class, error.getCause()); assertTrue(rejected.getMessage().contains("64")); assertEquals("Release", getEntity(created.getId().toString()).getCustomTypeName()); } diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoCloseIncidentIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoCloseIncidentIT.java index b52aa78d18fe..578ea1411e76 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoCloseIncidentIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AutoCloseIncidentIT.java @@ -15,7 +15,6 @@ import static org.awaitility.Awaitility.await; 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.assertTrue; @@ -229,20 +228,15 @@ void autoCloseDisabled_testPasses_incidentStaysOpen(TestNamespace ns) { // Verify incident is NOT resolved — no Resolved TCRS record appears await() .during(Duration.ofSeconds(10)) - .atMost(PIPELINE_TIMEOUT) + .atMost(Duration.ofSeconds(15)) .pollInterval(Duration.ofSeconds(2)) - .untilAsserted( - () -> { - final var statuses = listTcrsForStateId(client, stateId); - assertFalse(statuses.isEmpty(), "The incident history must remain available"); - assertTrue( - statuses.stream() - .noneMatch( - status -> - status.getTestCaseResolutionStatusType() - == TestCaseResolutionStatusTypes.Resolved), - () -> "Auto-close is disabled: " + statuses); - }); + .until( + () -> + listTcrsForStateId(client, stateId).stream() + .noneMatch( + r -> + r.getTestCaseResolutionStatusType() + == TestCaseResolutionStatusTypes.Resolved)); // Workflow should still be running String workflowInstanceId = taskRef.get().getWorkflowInstanceId().toString(); @@ -313,19 +307,28 @@ private Task findIncidentTaskForTestCase(OpenMetadataClient client, TestCase tes return null; } + @SuppressWarnings("unchecked") private List listTcrsForStateId( OpenMetadataClient client, UUID stateId) { - final String response = - client - .getHttpClient() - .executeForString( - HttpMethod.GET, - "/v1/dataQuality/testCases/testCaseIncidentStatus/stateId/" + stateId, - null, - RequestOptions.builder().build()); - return JsonUtils.readValue( - response, new TypeReference>() {}) - .getData(); + try { + String response = + client + .getHttpClient() + .executeForString( + HttpMethod.GET, + "/v1/dataQuality/testCases/testCaseIncidentStatus/stateId/" + stateId, + null, + RequestOptions.builder().build()); + + Map result = JsonUtils.readValue(response, new TypeReference<>() {}); + List data = (List) result.get("data"); + if (data == null) return List.of(); + return data.stream() + .map(d -> JsonUtils.convertValue(d, TestCaseResolutionStatus.class)) + .toList(); + } catch (Exception e) { + return List.of(); + } } @SuppressWarnings("unchecked") 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 index 708a32c998e7..70f71a01dbc6 100644 --- 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 @@ -149,6 +149,20 @@ void nativeDefinitionIriSurvivesUpdatesAndRestoration(TestNamespace ns) { 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); 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 index 34779ea4bb7a..90b0146ba8a1 100644 --- 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 @@ -18,8 +18,11 @@ 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; @@ -33,6 +36,64 @@ @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(); 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 index e841d961a1d1..4ec48f09552b 100644 --- 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 @@ -184,7 +184,7 @@ void existingApprovalWorkflowApprovesExactlyTheSubmittedRevision(TestNamespace n OntologyRelease ontology = capture(createOntology(ns)); Task task = submit(ns, Entity.ONTOLOGY_RELEASE, ontology.getFullyQualifiedName()); assertNotNull(task.getOntologyReview()); - assertEquals(ontology.getReviewRevision(), task.getOntologyReview().getReviewRevision()); + assertEquals(ontology.getId(), task.getOntologyReview().getEntityId()); assertEquals(EntityStatus.IN_REVIEW, current(ontology).getEntityStatus()); Task resolved = @@ -194,7 +194,7 @@ void existingApprovalWorkflowApprovesExactlyTheSubmittedRevision(TestNamespace n assertEquals(TaskEntityStatus.Approved, resolved.getStatus()); assertEquals(EntityStatus.APPROVED, current(ontology).getEntityStatus()); - assertEquals(ontology.getReviewRevision(), current(ontology).getReviewRevision()); + assertEquals(ontology.getReleaseRevision(), current(ontology).getReleaseRevision()); assertThrows( OpenMetadataException.class, () -> @@ -263,7 +263,7 @@ void cancelledReviewRequiresAFreshTaskAndCanBeResubmitted(TestNamespace ns) { assertEquals(TaskEntityStatus.Cancelled, cancelled.getStatus()); assertEquals(EntityStatus.DRAFT, current(ontology).getEntityStatus()); assertNull(current(ontology).getReviewTask()); - assertEquals(ontology.getReviewRevision(), current(ontology).getReviewRevision()); + assertEquals(ontology.getReleaseRevision(), current(ontology).getReleaseRevision()); assertThrows( OpenMetadataException.class, () -> 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 index 72d3df9344e1..6b1b892e6354 100644 --- 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 @@ -14,7 +14,6 @@ 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; @@ -53,13 +52,13 @@ public class IndependentOntologyAxiomIT extends BaseOntologyApprovalIT { private static final URI PROPERTY = URI.create("https://example.org/model/knows"); @Test - void axiomsBelongToOntologiesAndInheritTheirReviewers(TestNamespace ns) { + 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(), "reviewers"); - assertEquals(SharedEntities.get().USER1.getId(), stored.getReviewers().getFirst().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())); 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 index c3ac6766420f..2b243b0a1c8c 100644 --- 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 @@ -20,10 +20,10 @@ 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.OntologyConceptType; 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; @@ -67,8 +67,12 @@ void hierarchyReadsAndDeletionGuardsFollowTheStoredAxioms(TestNamespace ns) { } assertBadRequest( () -> - concepts.update( - party.getId(), party.withConceptType(OntologyConceptType.SKOS_CONCEPT))); + 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")))); 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 index 5b1434ce3764..7ecf9ec819cd 100644 --- 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 @@ -19,6 +19,7 @@ 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; @@ -107,11 +108,20 @@ void firstReleaseHasOnlyAdditionsAndWorkflowChangesDoNotChangeMeaning(TestNamesp 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 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 index 94a2f99016b7..ab27262429de 100644 --- 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 @@ -41,6 +41,7 @@ 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; @@ -84,9 +85,13 @@ void structuralGateProtectsDeclarationsAndReportsTheFrozenModelAfterDeletion(Tes () -> client .ontologyProperties() - .update( + .patch( declaration.getId(), - declaration.withKind(OntologyPropertyKind.DATA).withRange(List.of()))) + 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 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 index 14ece924020f..42829f2897db 100644 --- 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 @@ -230,6 +230,10 @@ void capturedMembersSurviveWorkingEditsAndDeletion(TestNamespace ns) { 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() 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 index 5eb628fd1a17..1c924f39a091 100644 --- 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 @@ -21,33 +21,6 @@ @Execution(ExecutionMode.CONCURRENT) public class IndependentOntologyResourceIT extends BaseOntologyIT { - @Test - void importsAreExplicitlyUnsupported(TestNamespace ns) { - Ontology imported = createEntity(createRequest(ns.prefix("imported"), ns)); - ns.trackRoot("ontology", imported); - assertEquals( - 400, - assertThrows( - OpenMetadataException.class, - () -> - createEntity( - createMinimalRequest(ns) - .withImports(List.of(imported.getEntityReference())))) - .getStatusCode()); - Ontology root = createEntity(createMinimalRequest(ns)); - ns.trackRoot("ontology", root); - assertEquals( - 400, - assertThrows( - OpenMetadataException.class, - () -> - patchEntity( - root.getId().toString(), - root.withImports(List.of(imported.getEntityReference())))) - .getStatusCode()); - assertEquals(List.of(), getEntity(root.getId().toString()).getImports()); - } - @Test void prefixesRequireUniqueNamesAndExactNamespaces(TestNamespace ns) { OntologyPrefix first = 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 index 525ab0d50961..4fc4d463876d 100644 --- 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 @@ -46,10 +46,8 @@ void archivingPreservesReleaseReadsAndDeletedMemberInheritance(TestNamespace ns) release.getContentDigest(), client.ontologyReleases().get(release.getId()).getContentDigest()); assertTrue(client.ontologyReleases().structure(release.getId()).getValid()); - var archived = - client.ontologyConcepts().get(concept.getId().toString(), "owners,reviewers", "deleted"); + var archived = client.ontologyConcepts().get(concept.getId().toString(), "owners", "deleted"); assertTrue(archived.getDeleted()); - assertFalse(archived.getReviewers().isEmpty()); var member = client .getHttpClient() @@ -154,9 +152,15 @@ void archivedOntologiesRemainReadableButCannotStartOrCompleteReviews(TestNamespa .create(reviewRequest(ns, Entity.ONTOLOGY_RELEASE, draft.getFullyQualifiedName()))); new OntologyReviewReconciler(Entity.getCollectionDAO()).reconcile(task.getId()); assertEquals( - EntityStatus.DRAFT, client.ontologyReleases().get(release.getId()).getEntityStatus()); + 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.Cancelled, client.tasks().get(task.getId().toString()).getStatus()); + TaskEntityStatus.Approved, + SdkClients.user1Client() + .tasks() + .resolve(task.getId().toString(), decision(TaskResolutionType.Approved)) + .getStatus()); } private static OntologyConcept concept(Ontology ontology) { 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 index d56fa1385f7a..1d6179453c72 100644 --- 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 @@ -186,7 +186,7 @@ void activeReviewCannotBeDeletedBeforeItIsClosed(boolean hardDelete, TestNamespa } @Test - void archivingTheOntologyCancelsItsReleaseReview(TestNamespace ns) { + 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()); @@ -194,7 +194,14 @@ void archivingTheOntologyCancelsItsReleaseReview(TestNamespace ns) { new OntologyReviewReconciler(Entity.getCollectionDAO()).reconcile(task.getId()); assertEquals( - TaskEntityStatus.Cancelled, + 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()); } 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/OntologyPropertyResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OntologyPropertyResourceIT.java index f7aa25b43d9a..bb6afa51955f 100644 --- 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 @@ -20,6 +20,7 @@ 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; @@ -28,6 +29,20 @@ @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 = @@ -78,20 +93,10 @@ void enumerationMetadataIsValidatedOnEveryWrite(TestNamespace ns) { new OntologyLiteral() .withValue("one") .withDatatypeIri(URI.create("http://www.w3.org/2001/XMLSchema#integer")))); - assertEquals( - 400, - assertThrows( - OpenMetadataException.class, - () -> entityService().update(created.getId().toString(), created)) - .getStatusCode()); + assertPropertyFieldRejected(created, "enumValues", created.getEnumValues()); created.setEnumValues( List.of(new OntologyLiteral().withValue("Gold"), new OntologyLiteral().withValue("Gold"))); - assertEquals( - 400, - assertThrows( - OpenMetadataException.class, - () -> entityService().update(created.getId().toString(), created)) - .getStatusCode()); + assertPropertyFieldRejected(created, "enumValues", created.getEnumValues()); assertEquals(List.of(), getEntity(created.getId().toString()).getEnumValues()); } @@ -172,12 +177,7 @@ void propertyKindConstrainsItsCharacteristics(TestNamespace ns) { OntologyProperty property = createEntity(request.withKind(OntologyPropertyKind.DATA)); property.setCharacteristics(Set.of(RelationshipCharacteristic.TRANSITIVE)); - assertEquals( - 400, - assertThrows( - OpenMetadataException.class, - () -> entityService().update(property.getId().toString(), property)) - .getStatusCode()); + assertPropertyFieldRejected(property, "characteristics", property.getCharacteristics()); assertEquals( Set.of(RelationshipCharacteristic.FUNCTIONAL), getEntity(property.getId().toString()).getCharacteristics()); 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 db23e2d685cf..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,27 +103,30 @@ 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, "<" + first + "> .").statusCode() / 100); - assertEquals(2, replace(dataset, "<" + second + "> .").statusCode() / 100); - assertFalse(contains(dataset, first)); - assertTrue(contains(dataset, second)); - final var invalid = replace(dataset, " ."); + 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, second)); + assertTrue(contains(dataset, graph, second)); } - private static HttpResponse replace(final String dataset, final String turtle) - throws Exception { + private static HttpResponse replace( + final String dataset, final String graph, final String turtle) throws Exception { return send( - request("/" + dataset + "/data?graph=" + GRAPH) + request("/" + dataset + "/data?graph=" + graph) .header("Authorization", basic("openmetadata:test-writer")) .header("Content-Type", "text/turtle") .PUT(HttpRequest.BodyPublishers.ofString(turtle))); @@ -134,7 +137,7 @@ private static HttpResponse replace(final String dataset, final String t 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) { @@ -161,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") @@ -169,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/TeamResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TeamResourceIT.java index 48099c015667..eb8f6c3d44a3 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TeamResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TeamResourceIT.java @@ -56,7 +56,6 @@ import org.openmetadata.schema.type.csv.CsvImportResult; import org.openmetadata.sdk.client.OpenMetadataClient; import org.openmetadata.sdk.exceptions.ForbiddenException; -import org.openmetadata.sdk.exceptions.InvalidRequestException; import org.openmetadata.sdk.models.ListParams; import org.openmetadata.sdk.models.ListResponse; import org.openmetadata.sdk.network.HttpMethod; @@ -74,6 +73,8 @@ public class TeamResourceIT extends BaseEntityIT { private static final String DIRECT_USER_ASSIGNMENT_ERROR = "Team is of type Department. Direct users can only be assigned to teams of type Group."; + private static final String UPDATE_DIRECT_USER_ASSIGNMENT_ERROR = + "Failed to update entity: " + DIRECT_USER_ASSIGNMENT_ERROR; { supportsEntityStatus = false; @@ -315,11 +316,10 @@ void test_departmentRejectsDirectUsersOnPatch(TestNamespace ns) { Team update = SdkClients.adminClient().teams().get(department.getId().toString(), "users"); update.setUsers(List.of(user.getEntityReference())); - InvalidRequestException exception = - assertThrows( - InvalidRequestException.class, () -> patchEntity(update.getId().toString(), update)); + Exception exception = + assertThrows(Exception.class, () -> patchEntity(update.getId().toString(), update)); - assertEquals(DIRECT_USER_ASSIGNMENT_ERROR, exception.getMessage()); + assertEquals(UPDATE_DIRECT_USER_ASSIGNMENT_ERROR, exception.getMessage()); } @Test @@ -389,12 +389,12 @@ void test_userCannotBeUpdatedToDepartment(TestNamespace ns) { User update = SdkClients.adminClient().users().get(user.getId().toString(), "teams"); update.setTeams(List.of(department.getEntityReference())); - InvalidRequestException exception = + Exception exception = assertThrows( - InvalidRequestException.class, + Exception.class, () -> SdkClients.adminClient().users().update(user.getId().toString(), update)); - assertEquals(DIRECT_USER_ASSIGNMENT_ERROR, exception.getMessage()); + assertEquals(UPDATE_DIRECT_USER_ASSIGNMENT_ERROR, exception.getMessage()); } @Test diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/BulkChangeEventIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/BulkChangeEventIT.java index 952b36e497d9..f0f82c788361 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/BulkChangeEventIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/BulkChangeEventIT.java @@ -172,10 +172,8 @@ void test_bulkUpdate_matchesSingleEntityPut(TestNamespace ns) throws Exception { * deleted} field change — so this now emits ENTITY_UPDATED where before the fix it emitted * nothing. New behaviour, matching the single-PUT path, pinned here so it cannot regress silently. */ - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void test_bulkUndeleteViaBulkPut_emitsEvent(boolean addColumn, TestNamespace ns) - throws Exception { + @Test + void test_bulkUndeleteViaBulkPut_emitsEvent(TestNamespace ns) throws Exception { String schemaFqn = setupSchema(ns); CreateTable create = table(ns, schemaFqn, "evt_undel", null); BulkApi.upsert("tables", List.of(create), false, BulkApi.botToken()); @@ -185,28 +183,13 @@ void test_bulkUndeleteViaBulkPut_emitsEvent(boolean addColumn, TestNamespace ns) softDelete(created.getId().toString()); long since = System.currentTimeMillis(); - BulkApi.upsert( - "tables", - List.of(addColumn ? withExtraColumn(create, "restored_col") : create), - false, - BulkApi.botToken()); + BulkApi.upsert("tables", List.of(create), false, BulkApi.botToken()); ChangeEvent event = awaitUpdatedEvent(created.getId(), since); assertNotNull(event.getChangeDescription(), "un-delete event must carry a change description"); assertTrue( allFieldNames(event).contains("deleted"), "bulk un-delete must record the 'deleted' field change, got: " + allFieldNames(event)); - final var deletedChange = - event.getChangeDescription().getFieldsUpdated().stream() - .filter(change -> "deleted".equals(change.getName())) - .findFirst() - .orElseThrow(); - assertEquals(true, deletedChange.getOldValue()); - assertEquals(false, deletedChange.getNewValue()); - assertEquals(false, getTable(fqn).getDeleted()); - if (addColumn) { - assertTrue(allFieldNames(event).contains("columns")); - } } /** diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/EventSubscriptionResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/EventSubscriptionResourceIT.java index 990f427896ed..7a1a91360f06 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/EventSubscriptionResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/alerts/EventSubscriptionResourceIT.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -161,12 +162,15 @@ void test_patchWebhookEndpointToLoopbackAddress_400(TestNamespace ns) { Webhook webhook = JsonUtils.convertValue(destination.getConfig(), Webhook.class); destination.withConfig(webhook.withEndpoint(LOOPBACK_ENDPOINT)); - InvalidRequestException rejected = + // The SDK wraps a patch failure, so the server's status is on the cause. + OpenMetadataException rejected = assertThrows( - InvalidRequestException.class, + OpenMetadataException.class, () -> patchEntity(subscription.getId().toString(), subscription), "Patching an endpoint to a loopback address should be rejected"); - assertEquals(400, rejected.getStatusCode()); + InvalidRequestException cause = + assertInstanceOf(InvalidRequestException.class, rejected.getCause()); + assertEquals(400, cause.getStatusCode()); } private List getWebhookDestination(TestNamespace ns) { @@ -999,6 +1003,7 @@ private void toggleEnabled(String subscriptionId, boolean enabled) { } } + /** The SDK wraps the transport failure, so the real status sits on a cause rather than the top. */ private static int statusCodeOf(Throwable failure) { for (Throwable cause = failure; cause != null; cause = cause.getCause()) { if (cause instanceof OpenMetadataException reported && reported.getStatusCode() > 0) { diff --git a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/EntityServiceBase.java b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/EntityServiceBase.java index 06824c9498f5..eaa518731b44 100644 --- a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/EntityServiceBase.java +++ b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/EntityServiceBase.java @@ -289,7 +289,7 @@ public T update(String id, T entity, String etag) throws OpenMetadataException { original = fetchEntity(id, null); } - originalNode = objectMapper.valueToTree(original); + originalNode = objectMapper.readTree(objectMapper.writeValueAsString(original)); } // Remove computed/read-only fields that cannot be patched @@ -306,8 +306,6 @@ public T update(String id, T entity, String etag) throws OpenMetadataException { return httpClient.execute( HttpMethod.PATCH, basePath + "/" + id, patch, getEntityClass(), options); - } catch (OpenMetadataException e) { - throw e; } catch (Exception e) { throw new OpenMetadataException("Failed to update entity: " + e.getMessage(), e); } 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 ff99745ca519..5dd7c011b15b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java @@ -124,8 +124,8 @@ import org.openmetadata.service.exception.JsonMappingExceptionMapper; import org.openmetadata.service.exception.OMErrorPageHandler; import org.openmetadata.service.fernet.Fernet; -import org.openmetadata.service.governance.workflows.OntologyReviewReconciler; 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; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ResourceRegistry.java b/openmetadata-service/src/main/java/org/openmetadata/service/ResourceRegistry.java index 9850510d4576..ba2b0567e9bf 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ResourceRegistry.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ResourceRegistry.java @@ -41,7 +41,6 @@ public class ResourceRegistry { MetadataOperation.EDIT_DESCRIPTION, MetadataOperation.EDIT_DISPLAY_NAME, MetadataOperation.EDIT_GLOSSARY_TERMS, - MetadataOperation.EDIT_ONTOLOGY_CONCEPTS, MetadataOperation.EDIT_TIER, MetadataOperation.CREATE_TASK, MetadataOperation.EDIT_TASK)); 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/OntologyReviewReconciler.java b/openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/OntologyReviewReconciler.java index 8e576fc7b30d..472c1cae4f00 100644 --- 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 @@ -107,7 +107,10 @@ public void reconcile(final UUID taskId) { OntologyApprovalWorkflow.validateCurrentReview(task); reconcileDecision(tasks, task, JsonUtils.readValue(json, Execution.class)); } catch (BadRequestException | AuthorizationException | EntityNotFoundException exception) { - cancelObsoleteReview(tasks, task, exception.getMessage()); + LOG.info( + "Ontology review {} awaits a valid review context: {}", + taskId, + exception.getMessage()); } } } 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/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index c221472c88bf..a507eac192c3 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 @@ -1249,7 +1249,6 @@ public final T getForInheritance(UUID id, Fields fields, Include include) { // find() above does not hydrate tags, so this ancestor would contribute only the tags it // inherits in turn and never its own -- see fetchInheritableRelationships. fetchAndSetTags(List.of(entity), fields); - fetchAndSetReviewers(List.of(entity), fields); if (!requiresParentForInheritance(entity, fields)) { return entity; } @@ -1316,7 +1315,6 @@ public void fetchInheritableRelationships(List entities, Fields fields) { // tags live in tag_usage and so arrive null. Leaving them that way makes each hop contribute // nothing but what it inherited in turn, and a tag set on a service never reaches a table. fetchAndSetTags(entities, fields); - fetchAndSetReviewers(entities, fields); } @SuppressWarnings("unchecked") @@ -4703,11 +4701,23 @@ 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 - final T beforeRestore = original; if (Boolean.TRUE.equals(original.getDeleted())) { try (var ignored = phase("putRestoreEntity")) { - original = restoreForUpdate(original, updated); + restoreEntity(updated.getUpdatedBy(), original.getId()); } } @@ -4727,7 +4737,6 @@ private PutResponse putAndStore( entityUpdater.update(); } } - entityUpdater.includeRestoration(beforeRestore); EventType change = entityUpdater.getChangeType(); try (var ignored = phase("putSetInheritedFields")) { setInheritedFields(updated, new Fields(allowedFields)); @@ -4764,10 +4773,9 @@ public final PutResponse updateForImport( private PutResponse putForImportAndStore(UriInfo uriInfo, T original, T updated) { // If the entity state is soft-deleted, recursively undelete the entity and it's children - final T beforeRestore = original; if (Boolean.TRUE.equals(original.getDeleted())) { try (var ignored = phase("putRestoreEntityImport")) { - original = restoreForUpdate(original, updated); + restoreEntity(updated.getUpdatedBy(), original.getId()); } } @@ -4776,7 +4784,6 @@ private PutResponse putForImportAndStore(UriInfo uriInfo, T original, T updat try (var ignored = phase("putEntityUpdateImport")) { entityUpdater.updateForImport(); } - entityUpdater.includeRestoration(beforeRestore); EventType change = entityUpdater.getChangeType(); try (var ignored = phase("putSetInheritedFieldsImport")) { setInheritedFields(updated, new Fields(allowedFields)); @@ -4787,15 +4794,6 @@ private PutResponse putForImportAndStore(UriInfo uriInfo, T original, T updat return new PutResponse<>(Status.OK, withHref(uriInfo, updated), change); } - private T restoreForUpdate(T original, T updated) { - // Restore writes its own version; the following update must compare against that revision. - PutResponse restored = restoreEntity(updated.getUpdatedBy(), original.getId()); - updated.setDeleted(false); - return restored == null - ? get(null, original.getId(), putFields, NON_DELETED, false) - : restored.getEntity(); - } - /** * Use method with ChangeContext */ @@ -5977,7 +5975,7 @@ protected R executeEntityWrite(List entities, Supplier work) { return work.get(); } - private void flushInOneTransaction(List entities, Runnable work) { + protected void flushInOneTransaction(List entities, Runnable work) { executeEntityWrite( entities, () -> { @@ -7613,9 +7611,7 @@ public final PutResponse restoreEntity(String updatedBy, UUID id) { // Repositories are instantiated directly rather than as JDBI SQL-object proxies, so an // annotation here would not create a transaction. The explicit boundary also drains deferred // credential/cache work only after the restore commits. - return executeEntityWrite( - List.of(find(id, ALL)), - () -> executeInTransaction(() -> restoreEntityInternal(updatedBy, id))); + return executeInTransaction(() -> restoreEntityInternal(updatedBy, id)); } private PutResponse restoreEntityInternal(String updatedBy, UUID id) { @@ -7624,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); @@ -11501,21 +11501,6 @@ public final EventType getChangeType() { return incrementalFieldsChanged() ? ENTITY_UPDATED : ENTITY_NO_CHANGE; } - private void includeRestoration(final T beforeRestore) { - if (Boolean.TRUE.equals(beforeRestore.getDeleted())) { - // The restore already persisted its version; combine its delta only in this request's - // event. - incrementalChangeDescription = - JsonUtils.deepCopy(incrementalChangeDescription, ChangeDescription.class); - incrementalChangeDescription.setPreviousVersion(beforeRestore.getVersion()); - incrementalChangeDescription - .getFieldsUpdated() - .add(new FieldChange().withName(FIELD_DELETED).withOldValue(true).withNewValue(false)); - updated.setIncrementalChangeDescription(incrementalChangeDescription); - entityChanged = true; - } - } - public final boolean recordChange(String field, K orig, K updated) { return recordChange(field, orig, updated, false, objectMatch, true); } @@ -14365,15 +14350,13 @@ && isSourceHashUnchanged(entity, hydratedOriginalByFqn, updateFrequencyByFqn)) { entity.setUpdatedBy(userName); entity.setUpdatedAt(System.currentTimeMillis()); - final T beforeRestore = original; if (Boolean.TRUE.equals(original.getDeleted())) { - original = restoreForUpdate(original, entity); + restoreEntity(entity.getUpdatedBy(), original.getId()); } EntityUpdater updater = getUpdater(original, entity, Operation.PUT, null); updater.setOverrideMetadata(overrideMetadata); updater.updateWithDeferredStore(); - updater.includeRestoration(beforeRestore); updaters.add(updater); } catch (Exception e) { failedRequests.add( 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/OntologyEntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyEntityRepository.java index c6b3292deb87..435fa921ae59 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyEntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyEntityRepository.java @@ -105,20 +105,23 @@ public Ontology getParentEntity(final T entity, final String fields) { @Override protected String getInheritableFields() { - return "owners,reviewers"; + return Entity.ONTOLOGY_RELEASE.equals(entityType) ? "owners,reviewers" : "owners"; } @Override protected boolean requiresParentForInheritance(final T entity, final Fields fields) { return entity.getOntology() != null - && (fields.contains("reviewers") || requiresParentForOwnersOrDomains(entity, fields)); + && ((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); - inheritReviewers(entity, fields, parent); + if (Entity.ONTOLOGY_RELEASE.equals(entityType)) { + inheritReviewers(entity, fields, getParentEntity(entity, "reviewers")); + } } @Override @@ -176,9 +179,22 @@ private void requireUnchangedName(final T original, final T updated) { @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) { @@ -194,7 +210,7 @@ void updateEntityStatus(final boolean consolidatingChanges) { @Override protected void entitySpecificUpdate(final boolean consolidatingChanges) { requireUnchangedIdentity(); - setInheritedFields(updated, getFields("owners,reviewers")); + setInheritedFields(updated, getFields(getInheritableFields())); final JsonNode before = JsonUtils.getObjectMapper().valueToTree(original); final JsonNode after = JsonUtils.getObjectMapper().valueToTree(updated); for (final String field : definitionFields) { 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 index cb4ff133a712..42c8239ecdc2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReleaseRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyReleaseRepository.java @@ -194,12 +194,8 @@ public void prepareReview(final Task task) { if (nullOrEmpty(entity.getReviewers())) { throw new BadRequestException("Configure ontology reviewers before submitting for approval"); } - final WorkflowDefinition workflow = requireReviewWorkflow(task); - final OntologyReviewTarget target = - OntologyReview.target(entity) - .withWorkflowDefinitionVersion(workflow.getVersion()) - .withWorkflowDefinitionDigest(OntologyReview.workflowDigest(workflow)); - task.setOntologyReview(target); + requireReviewWorkflow(task); + task.setOntologyReview(OntologyReview.target(entity)); task.setAbout(entity.getEntityReference()); task.setAssignees(entity.getReviewers()); task.setReviewers(entity.getReviewers()); 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 index 27a83c032c05..1e97225d9d07 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/OntologyRepository.java @@ -1,19 +1,22 @@ package org.openmetadata.service.jdbi3; -import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; - 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 { @@ -25,14 +28,13 @@ public OntologyRepository() { Ontology.class, Entity.getCollectionDAO().ontologyEntityDAO(), COLLECTION_PATH, - "iri,namespace,layer,prefixes,imports"); + "iri,namespace,layer,prefixes"); } @Override protected void validateDefinition(final Ontology entity) { requireAbsoluteIri(entity.getNamespace(), "namespace"); OntologyLayerValidator.validatePrefixes(entity.getPrefixes()); - validateImports(entity); } @Override @@ -65,10 +67,23 @@ private void requireNoRetainedReleases(final UUID id) { } } - private void validateImports(final Ontology ontology) { - if (!listOrEmpty(ontology.getImports()).isEmpty()) { - throw new BadRequestException("Cross-ontology imports are not supported in this release"); - } + 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) { 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 6d4937e0d5a9..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 @@ -64,9 +64,9 @@ import org.openmetadata.service.events.lifecycle.handlers.IncidentTcrsSyncHandler; import org.openmetadata.service.exception.CatalogExceptionMessage; import org.openmetadata.service.exception.EntityNotFoundException; -import org.openmetadata.service.governance.workflows.OntologyApprovalWorkflow; 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; @@ -1589,12 +1589,16 @@ protected void postUpdate(Task original, Task updated) { } @Override - protected void flushInOneTransaction(final Runnable work) { - executeInTransaction( - () -> { - work.run(); - return null; - }); + 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) { 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 41d3ad98f29a..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 @@ -39,7 +39,11 @@ import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.jdbi3.GlossaryRepository; 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; @@ -90,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; } @@ -303,7 +311,13 @@ private static Dependencies productionDependencies( new OntologyChangeOperationExecutor( (GlossaryRepository) Entity.getEntityRepository(Entity.GLOSSARY), termRepository, - 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 6a30e51b63ff..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,10 +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.OntologyChangeSet; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.OntologyAttribute; @@ -43,16 +45,37 @@ public final class OntologyChangeOperationExecutor { private final GlossaryTermRepository termRepository; private final GlossaryRepository glossaryRepository; private final Clock clock; + private final NativeOntologyChangeExecutor nativeExecutor; public OntologyChangeOperationExecutor( final GlossaryRepository glossaryRepository, final GlossaryTermRepository termRepository, 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.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 = @@ -67,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, DELETE_AXIOM -> throw new BadRequestException( - "Axioms must be authored in an ontology, not a glossary change set"); + case UPSERT_CONCEPT, + DELETE_CONCEPT, + UPSERT_PROPERTY, + DELETE_PROPERTY, + UPSERT_AXIOM, + DELETE_AXIOM -> nativeExecutor().execute(uriInfo, user, operation); }; return outcome; } 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 11b951ae4844..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; @@ -45,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); @@ -116,7 +124,12 @@ private void validateScope( operation, operation.getTargetId(), scope, plannedTerms); case ADD_RELATIONSHIP, UPDATE_RELATIONSHIP, DELETE_RELATIONSHIP -> validateRelationshipScope( operation, scope, plannedTerms); - case UPSERT_AXIOM, DELETE_AXIOM -> throw new BadRequestException( + 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"); } } 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 c6a9fd644ca8..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; } @@ -294,7 +300,12 @@ private static PayloadKind payloadKind(final OntologyChangeOperationType operati .RELATIONSHIP; case UPSERT_ATTRIBUTE, DELETE_ATTRIBUTE -> PayloadKind.ATTRIBUTE; case UPSERT_MAPPING, DELETE_MAPPING -> PayloadKind.MAPPING; - case UPSERT_AXIOM, DELETE_AXIOM -> throw new BadRequestException( + 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; 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 index f4449211138b..6739116d8588 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseCapture.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseCapture.java @@ -180,7 +180,8 @@ private void add(final MemberKind kind, final OntologyDefinition entity) { access.authorize().accept(entity); final OntologyReleaseMember member = OntologyReleaseManifest.member( - releaseId, kind, entity.getId(), JsonUtils.getObjectMapper().valueToTree(entity)); + 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"); 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 index 2a78be2e92b8..31274030b220 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseManifest.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseManifest.java @@ -143,7 +143,8 @@ private static void requireValidMember( member.getPayload()); if (!Objects.equals(member.getPayloadDigest(), expected.getPayloadDigest()) || !Objects.equals(member.getPayloadBytes(), expected.getPayloadBytes()) - || !Objects.equals(member.getSourceVersion(), expected.getSourceVersion())) { + || (member.getPayload().hasNonNull("version") + && !Objects.equals(member.getSourceVersion(), expected.getSourceVersion()))) { throw new BadRequestException("Ontology release member integrity check failed"); } if (previous != null 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..8f0b5c2b5caf --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseQuery.java @@ -0,0 +1,47 @@ +package org.openmetadata.service.ontology; + +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 index 672d0920ffb8..c2cdbbe47ebc 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseReader.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseReader.java @@ -60,6 +60,10 @@ public static void requireBounds(final int count, final long bytes) { 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()) @@ -83,7 +87,7 @@ public static OntologyDefinition definition(final OntologyReleaseMember member) default -> throw new BadRequestException( "Unsupported ontology release member: " + member.getMemberKind()); }; - final var value = JsonUtils.getObjectMapper().convertValue(member.getPayload(), type); + 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"); } 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 index b13aa887014c..9c5dc3cefabc 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReview.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReview.java @@ -1,14 +1,12 @@ package org.openmetadata.service.ontology; import com.fasterxml.jackson.databind.node.ObjectNode; -import java.util.List; 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.governance.workflows.WorkflowDefinition; import org.openmetadata.schema.type.EntityStatus; import org.openmetadata.schema.type.OntologyReviewTarget; import org.openmetadata.schema.utils.JsonUtils; @@ -16,29 +14,10 @@ /** Content identity shared by ontology persistence and the existing approval-task lifecycle. */ public final class OntologyReview { - private static final List OPERATIONAL_FIELDS = - List.of( - "version", - "updatedAt", - "updatedBy", - "impersonatedBy", - "href", - "changeDescription", - "incrementalChangeDescription", - "entityStatus", - "reviewRevision", - "reviewAuthor", - "reviewTask", - "owners", - "reviewers", - "superClasses", - "subClasses"); - private OntologyReview() {} public static void initialize(final OntologyRelease entity) { entity.setEntityStatus(EntityStatus.DRAFT); - entity.setReviewRevision(1L); entity.setReviewAuthor(entity.getUpdatedBy()); entity.setReviewTask(null); } @@ -48,7 +27,6 @@ public static OntologyReviewTarget target(final OntologyRelease entity) { .withEntityId(entity.getId()) .withOntologyId(ontologyId(entity)) .withEntityVersion(entity.getVersion()) - .withReviewRevision(entity.getReviewRevision()) .withContentDigest(digest(entity)) .withAuthor(entity.getReviewAuthor()); } @@ -67,7 +45,6 @@ public static void validateTarget( || target.getEntityVersion() == null || !Objects.equals(entity.getId(), target.getEntityId()) || !Objects.equals(ontologyId(entity), target.getOntologyId()) - || !Objects.equals(entity.getReviewRevision(), target.getReviewRevision()) || !Objects.equals(digest(entity), target.getContentDigest())) { throw new BadRequestException( "Ontology content changed; submit its current revision for review"); @@ -75,21 +52,18 @@ public static void validateTarget( } public static String digest(final OntologyDefinition entity) { - final ObjectNode content = JsonUtils.getObjectMapper().valueToTree(entity); - content.remove(OPERATIONAL_FIELDS); - if (content.get("ontology") instanceof ObjectNode reference) { - reference.retain("id", "type"); + 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("ontology-review-v2\n" + OntologyJson.canonical(content)); + 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(); } - - public static String workflowDigest(final WorkflowDefinition workflow) { - final ObjectNode policy = JsonUtils.getObjectMapper().valueToTree(workflow); - policy.retain("id", "trigger", "nodes", "edges", "config", "suspended"); - return DigestUtils.sha256Hex("ontology-workflow-v1\n" + OntologyJson.canonical(policy)); - } } 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/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/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 7a3a102683cc..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,6 +52,7 @@ 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; @@ -60,7 +61,9 @@ import org.openmetadata.service.jdbi3.ListFilter; 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; @@ -145,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); } @@ -200,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) @@ -216,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)); @@ -302,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() @@ -328,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/OntologyMapper.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ontology/OntologyMapper.java index deefa21ee91f..ece5e8b7747d 100644 --- 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 @@ -12,7 +12,6 @@ public Ontology createToEntity(final CreateOntology request, final String user) .withNamespace(request.getNamespace()) .withLayer(request.getLayer()) .withPrefixes(request.getPrefixes()) - .withImports(request.getImports()) .withProvider(request.getProvider()); } } 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 index 353c4d146f95..0617a83d6b70 100644 --- 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 @@ -99,9 +99,9 @@ public OntologyRelease getVersion( @Path("/{id}/diff") @Operation( operationId = "compareOntologyReleases", - summary = "Compare governed definitions and import pins across two sealed release closures", + summary = "Compare definitions from two sealed releases", description = - "Omitting the base compares with an empty ontology. 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.") + "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, @@ -110,8 +110,9 @@ public OntologyReleaseDiff diff( @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 = - baseReleaseId == null ? null : get(uriInfo, securityContext, baseReleaseId); + effectiveBase == null ? null : get(uriInfo, securityContext, effectiveBase); return comparison.compare( release, base, @@ -124,7 +125,7 @@ public OntologyReleaseDiff diff( @Path("/{id}/structure") @Operation( operationId = "getOntologyReleaseStructure", - summary = "Check property declarations and axiom structure across a sealed import closure", + 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( 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 index 9c590226c502..6468e94a7c63 100644 --- 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 @@ -19,9 +19,11 @@ 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; @@ -30,6 +32,7 @@ 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; @@ -43,6 +46,7 @@ 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; @@ -62,7 +66,10 @@ 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; @@ -105,6 +112,59 @@ public OntologyResource(final Authorizer authorizer, final Limits limits) { 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( 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 index a0037808d911..4da29cb48e86 100644 --- 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 @@ -3,8 +3,11 @@ import jakarta.ws.rs.core.SecurityContext; import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.OntologyDefinition; +import org.openmetadata.schema.entity.data.Ontology; +import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.EntityNotFoundException; import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.policyevaluator.OperationContext; @@ -28,6 +31,18 @@ private static > void requireView( final OntologyDefinition definition, final EntityRepository repository) { final String type = repository.getEntityType(); + try { + definition.setOwners( + repository + .get(null, definition.getId(), repository.getFields("owners"), Include.ALL, false) + .getOwners()); + } catch (EntityNotFoundException exception) { + if (definition.getOntology() == null) { + throw exception; + } + final Ontology parent = Entity.getEntity(definition.getOntology(), "owners", Include.ALL); + definition.setOwners(parent.getOwners()); + } authorizer.authorize( context, new OperationContext(type, MetadataOperation.VIEW_ALL), 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 bb09b861628b..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 @@ -86,8 +86,8 @@ import org.openmetadata.service.ResourceRegistry; import org.openmetadata.service.context.review.MemoryConflictResolution; import org.openmetadata.service.exception.BadRequestException; -import org.openmetadata.service.governance.workflows.OntologyApprovalWorkflow; 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; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/DefaultAuthorizer.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/DefaultAuthorizer.java index bfba6b07bb71..bf8cc83449a7 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/DefaultAuthorizer.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/DefaultAuthorizer.java @@ -36,7 +36,7 @@ @Slf4j public class DefaultAuthorizer implements Authorizer { - private static final DefaultAuthorizer DIRECT_AUTHORIZER = new DefaultAuthorizer(); + private static final DefaultAuthorizer DIRECT_READ_AUTHORIZER = new DefaultAuthorizer(); @Override public void init(OpenMetadataApplicationConfig config) { @@ -136,12 +136,12 @@ public void authorize( } } - /** Authorize a resolved user on paths that do not carry the JAX-RS security context. */ + /** Authorize a resolved user on read paths that do not carry the JAX-RS security context. */ public static void authorizeUser( String userName, OperationContext operationContext, ResourceContextInterface resourceContext) { - DIRECT_AUTHORIZER.authorizeSubject( + DIRECT_READ_AUTHORIZER.authorizeSubject( subjectContextForUserName(userName), operationContext, resourceContext); } 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 5ad16bf4fa88..7465c45925c8 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,6 +57,9 @@ 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.metadata.MetadataCollectionService; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowLifecycleResolver.java b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowLifecycleResolver.java index b3602978df2b..4d8d641d7632 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowLifecycleResolver.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowLifecycleResolver.java @@ -36,7 +36,6 @@ import org.openmetadata.schema.type.TaskCategory; import org.openmetadata.schema.type.TaskEntityStatus; import org.openmetadata.schema.type.TaskEntityType; -import org.openmetadata.schema.type.TaskPriority; import org.openmetadata.schema.type.TaskResolutionType; import org.openmetadata.schema.utils.JsonUtils; import org.openmetadata.service.Entity; @@ -609,9 +608,7 @@ public Map toVariables() { variables.put(TASK_DESCRIPTION, task.getDescription()); variables.put(TASK_TYPE, task.getType() != null ? task.getType().value() : null); variables.put(TASK_CATEGORY, task.getCategory() != null ? task.getCategory().value() : null); - variables.put( - TASK_PRIORITY, - task.getPriority() != null ? task.getPriority().value() : TaskPriority.Medium.value()); + variables.put(TASK_PRIORITY, task.getPriority() != null ? task.getPriority().value() : null); variables.put(TASK_PAYLOAD, serializeWorkflowVariable(task.getPayload())); variables.put(TASK_DUE_DATE, task.getDueDate()); variables.put( 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 index 3befe88c7a0d..e944dfe66fc2 100644 --- 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 @@ -23,7 +23,7 @@ class OntologyApprovalWorkflowTest { @Test void ontologyReviewCannotBeRetargetedOrConvertedToAnotherTaskType() { Task original = reviewTask(); - assertRejected(original, task -> task.getOntologyReview().setReviewRevision(2L)); + 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())); @@ -101,6 +101,6 @@ private static Task reviewTask() { .withAbout(new EntityReference().withId(entityId).withType(Entity.ONTOLOGY)) .withWorkflowDefinitionId(UUID.randomUUID()) .withOntologyReview( - new OntologyReviewTarget().withEntityId(entityId).withReviewRevision(1L)); + 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 index 0e1bbdd9a4f5..2a2ccc4b4157 100644 --- 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 @@ -33,7 +33,7 @@ void replayCannotResolveAnotherTaskOrRevision() { BadRequestException.class, () -> decision.requireTarget(JsonUtils.deepCopy(task, Task.class).withId(UUID.randomUUID()))); - task.getOntologyReview().setReviewRevision(2L); + task.getOntologyReview().setContentDigest("b".repeat(64)); assertThrows(BadRequestException.class, () -> decision.requireTarget(task)); } @@ -67,7 +67,7 @@ private static Task task() { .withOntologyReview( new OntologyReviewTarget() .withEntityId(UUID.randomUUID()) - .withReviewRevision(1L) + .withContentDigest("a".repeat(64)) .withContentDigest("reviewed")); } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryBulkEventTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryBulkEventTest.java deleted file mode 100644 index fdea69057f10..000000000000 --- a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryBulkEventTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.openmetadata.service.jdbi3; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.CALLS_REAL_METHODS; -import static org.mockito.Mockito.mock; - -import java.util.List; -import java.util.UUID; -import org.junit.jupiter.api.Test; -import org.openmetadata.schema.entity.data.Topic; -import org.openmetadata.schema.type.ChangeDescription; -import org.openmetadata.schema.type.ChangeEvent; -import org.openmetadata.schema.type.EventType; -import org.openmetadata.schema.type.FieldChange; -import org.openmetadata.schema.utils.JsonUtils; - -class EntityRepositoryBulkEventTest { - @Test - void updateEventUsesTheRequestDeltaInsteadOfTheStoredVersionDelta() { - final var topic = topic(); - final var repository = mock(TopicRepository.class, CALLS_REAL_METHODS); - final var event = - JsonUtils.readValue( - repository - .buildChangeEventJsonForBulkOperation(topic, EventType.ENTITY_UPDATED, "admin") - .orElseThrow(), - ChangeEvent.class); - - assertEquals(topic.getIncrementalChangeDescription(), event.getChangeDescription()); - assertEquals(0.2, event.getPreviousVersion()); - assertEquals(0.3, topic.getChangeDescription().getPreviousVersion()); - } - - @Test - void restoreEventRetainsTheStoredDeltaAndNoChangeProducesNoEvent() { - final var topic = topic(); - final var repository = mock(TopicRepository.class, CALLS_REAL_METHODS); - final var event = - JsonUtils.readValue( - repository - .buildChangeEventJsonForBulkOperation(topic, EventType.ENTITY_RESTORED, "admin") - .orElseThrow(), - ChangeEvent.class); - - assertEquals(topic.getChangeDescription(), event.getChangeDescription()); - assertEquals(0.3, event.getPreviousVersion()); - assertTrue( - repository - .buildChangeEventJsonForBulkOperation(topic, EventType.ENTITY_NO_CHANGE, "admin") - .isEmpty()); - } - - private static Topic topic() { - return new Topic() - .withId(UUID.randomUUID()) - .withName("orders") - .withFullyQualifiedName("service.orders") - .withUpdatedAt(1L) - .withVersion(0.4) - .withChangeDescription(new ChangeDescription().withPreviousVersion(0.3)) - .withIncrementalChangeDescription( - new ChangeDescription() - .withPreviousVersion(0.2) - .withFieldsUpdated( - List.of( - new FieldChange() - .withName("deleted") - .withOldValue(true) - .withNewValue(false)))); - } -} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryRestoreTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryRestoreTest.java index 45e71223ae57..e315f0f55820 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryRestoreTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/EntityRepositoryRestoreTest.java @@ -713,6 +713,7 @@ void bulkHardDeleteSubtree_cascadeCovered_skipsPerEntityFqnCleanupAndBatchesUsag // Per-entity FQN-keyed cleanup is skipped (covered by the ancestor's prefix cascade). verify(fieldRelationshipDAO, never()).deleteAllByPrefix(any()); verify(tagUsageDAO, never()).deleteTagLabelsByTargetPrefix(any()); + verify(tagUsageDAO, never()).deleteTagLabelsByFqn(any()); // usage is id-keyed: cleared once, batched, never per-entity. verify(usageDAO, never()).delete(any(UUID.class)); verify(usageDAO, times(1)).deleteByIds(anyList()); 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 index edbd80a6450a..2d6209fe3515 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyEntityDAOTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyEntityDAOTest.java @@ -190,14 +190,20 @@ void migrationPreservesTermChangeSetsWhileResettingLegacyAxiomOperations(Connect 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")) { + 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","state":"DRAFT","updatedAt":1, + {"id":"%s","name":"%s",%s"state":"DRAFT","updatedAt":1, "updatedBy":"admin","deleted":false,"operations":[{"operationType":"%s"}]} """ - .formatted(id, operation, operation); + .formatted( + id, + operation, + nativeDraft ? "\"ontology\":{\"id\":\"" + glossaryId + "\"}," : "", + operation.replace("NATIVE_", "")); handle .createUpdate( "INSERT INTO ontology_change_set_entity (fqnHash, json) VALUES (:hash, " @@ -208,9 +214,10 @@ void migrationPreservesTermChangeSetsWhileResettingLegacyAxiomOperations(Connect .execute(); handle .createUpdate( - "INSERT INTO entity_relationship (fromId, toId, fromEntity, toEntity, relation) VALUES (:parent, :child, 'glossary', 'ontologyChangeSet', 0)") + "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( @@ -224,20 +231,20 @@ void migrationPreservesTermChangeSetsWhileResettingLegacyAxiomOperations(Connect migrate(handle, dialect); migrate(handle, dialect); assertEquals( - List.of("UPSERT_TERM"), + List.of("NATIVE_UPSERT_AXIOM", "UPSERT_TERM"), handle - .createQuery("SELECT name FROM ontology_change_set_entity") + .createQuery("SELECT name FROM ontology_change_set_entity ORDER BY name") .mapTo(String.class) .list()); assertEquals( - 1, + 2, handle .createQuery("SELECT COUNT(*) FROM entity_relationship WHERE fromId = :id") .bind("id", glossaryId) .mapTo(Integer.class) .one()); assertEquals( - 1, + 2, handle .createQuery( "SELECT COUNT(*) FROM entity_extension WHERE jsonSchema = 'ontologyChangeSet'") @@ -275,7 +282,7 @@ void cancellingTheCurrentReviewReturnsTheDefinitionToDraft(ConnectionType dialec assertEquals(TaskEntityStatus.Cancelled, cancelled.getStatus()); assertEquals(EntityStatus.DRAFT, definition.getEntityStatus()); assertNull(definition.getReviewTask()); - assertEquals(1L, definition.getReviewRevision()); + assertEquals(1L, definition.getReleaseRevision()); assertEquals("admin", definition.getReviewAuthor()); Task closedAgain = fixture @@ -411,7 +418,7 @@ void reviewLocksSeeEditsAndArchivalCommittedWhileWaiting( .withUpdatedBy("admin"), "review"); final OntologyRelease changed = - fixture.definition().withVersion(0.2).withReviewRevision(2L).withDeleted(archived); + fixture.definition().withVersion(0.2).withDeleted(archived); dao.ontologyStateDAO().initialize(fixture.ontologyId()); try (var executor = Executors.newSingleThreadExecutor()) { handle.begin(); @@ -446,8 +453,8 @@ void reviewLocksSeeEditsAndArchivalCommittedWhileWaiting( instanceof EntityNotFoundException); } else { assertEquals( - changed.getReviewRevision(), - assertDoesNotThrow(() -> review.get(10, TimeUnit.SECONDS)).getReviewRevision()); + changed.getVersion(), + assertDoesNotThrow(() -> review.get(10, TimeUnit.SECONDS)).getVersion()); } } }); @@ -512,6 +519,7 @@ private static void withReviewRepositories( new EntityRelationshipRepository(Entity.getCollectionDAO())); try { new SystemRepository(); + new WorkflowDefinitionRepository(); new OntologyRepository(); new OntologyReleaseRepository(); assertion.accept(handle, reviewFixture(new TaskRepository())); @@ -540,7 +548,6 @@ private static ReviewFixture reviewFixture(TaskRepository tasks) { .withUpdatedBy("admin") .withUpdatedAt(1L) .withEntityStatus(EntityStatus.IN_REVIEW) - .withReviewRevision(1L) .withReviewAuthor("admin") .withReviewTask(taskId); Entity.getCollectionDAO().ontologyReleaseDAO().insert(definition, name); @@ -658,7 +665,6 @@ private static void assertDecisionSurvivesAsyncCompletion( .withId(UUID.randomUUID()) .withOntology(reference(ontology())) .withVersion(0.1) - .withReviewRevision(1L) .withReviewAuthor("author")), TaskResolutionType.Approved, "bob", 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 index a61dda323c07..f3570e52aac0 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyTestDatabase.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyTestDatabase.java @@ -114,7 +114,7 @@ static void migrateRepositoryTables(Handle handle, ConnectionType dialect) { .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) \\(.*")) + "(?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"); diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v210/FlowableCharsetMigrationTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v210/FlowableCharsetMigrationTest.java index 7e8b65fcb018..e6556d1c8a71 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v210/FlowableCharsetMigrationTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v210/FlowableCharsetMigrationTest.java @@ -59,11 +59,7 @@ void mysqlUpgradeChangesTheCurrentDatabaseDefault() { final List statements = MigrationFile.parseSQLFile(schemaChanges.toFile(), MYSQL); - assertEquals( - 1, - statements.stream() - .filter(statement -> statement.endsWith(ALTER_CURRENT_DATABASE)) - .count()); + assertTrue(statements.getLast().endsWith(ALTER_CURRENT_DATABASE), statements.getLast()); } @Test 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/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 index 76e7f485f103..73e1d0ad2436 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReviewTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/ontology/OntologyReviewTest.java @@ -11,7 +11,6 @@ import org.junit.jupiter.api.Test; 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.OntologyReviewTarget; @@ -30,17 +29,32 @@ void governanceAssignmentsDoNotInvalidateApprovedMeaning() { List.of(new EntityReference().withId(UUID.randomUUID()).withType("team"))); assertEquals(OntologyReview.digest(approved), OntologyReview.digest(reassigned)); assertEquals(EntityStatus.APPROVED, reassigned.getEntityStatus()); - assertEquals(approved.getReviewRevision(), reassigned.getReviewRevision()); 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(1L, ontology.getReviewRevision()); assertEquals("author", ontology.getReviewAuthor()); } } @@ -81,7 +95,6 @@ void reviewTargetCannotBeMovedToAnotherOntology() { .withId(UUID.randomUUID()) .withOntology(new EntityReference().withId(UUID.randomUUID()).withType("ontology")) .withVersion(0.1) - .withReviewRevision(1L) .withReviewAuthor("author"); final OntologyReviewTarget target = OntologyReview.target(concept); @@ -130,22 +143,6 @@ void reviewerOrderingDoesNotChangeTheReviewTarget() { assertEquals(OntologyReview.digest(original), OntologyReview.digest(reordered)); } - @Test - void workflowFingerprintDetectsPolicyChangesWithinOneVersion() { - WorkflowDefinition original = - new WorkflowDefinition().withId(UUID.randomUUID()).withVersion(0.1).withSuspended(false); - WorkflowDefinition changed = - JsonUtils.deepCopy(original, WorkflowDefinition.class).withSuspended(true); - WorkflowDefinition audited = - JsonUtils.deepCopy(original, WorkflowDefinition.class) - .withUpdatedAt(123L) - .withUpdatedBy("admin"); - - assertNotEquals( - OntologyReview.workflowDigest(original), OntologyReview.workflowDigest(changed)); - assertEquals(OntologyReview.workflowDigest(original), OntologyReview.workflowDigest(audited)); - } - private static OntologyRelease ontology() { return new OntologyRelease() .withId(UUID.randomUUID()) @@ -157,7 +154,6 @@ private static OntologyRelease ontology() { .withOntologyIri(URI.create("https://example.org/sales")) .withUpdatedBy("author") .withEntityStatus(EntityStatus.DRAFT) - .withReviewRevision(1L) .withReviewAuthor("author"); } } diff --git a/openmetadata-service/src/test/resources/rdf/projection-entities.json b/openmetadata-service/src/test/resources/rdf/projection-entities.json index 5f04da7c004d..b43137a20d30 100644 --- a/openmetadata-service/src/test/resources/rdf/projection-entities.json +++ b/openmetadata-service/src/test/resources/rdf/projection-entities.json @@ -4,11 +4,8 @@ "fields": { "ontologyReview": { "entityId": "00000000-0000-0000-0000-000000000001", - "reviewRevision": 1, "contentDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "author": "author", - "workflowDefinitionVersion": 0.1, - "workflowDefinitionDigest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + "author": "author" } } }, @@ -27,8 +24,7 @@ "tags": [ {"tagFQN": "PII.Sensitive", "source": "Classification", "labelType": "Manual", "state": "Confirmed", "description": "Personal data"}, {"tagFQN": "Business.Customer", "source": "Glossary", "labelType": "Derived", "state": "Suggested"}, - {"tagFQN": "Tier.Tier1", "source": "Classification"}, - {"tagFQN": "Sales.Customer", "source": "Ontology", "metadata": {"ontology": {"ontologyId": "00000000-0000-0000-0000-000000000004", "conceptId": "00000000-0000-0000-0000-000000000005"}}} + {"tagFQN": "Tier.Tier1", "source": "Classification"} ], "certification": {"tagLabel": {"tagFQN": "Certification.Gold", "source": "Classification"}, "appliedDate": 1000, "expiryDate": 2000}, "lifeCycle": { 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 index 87bce9f1d19a..b04c0121554c 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/data/createOntology.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntology.json @@ -38,9 +38,6 @@ }, "prefixes": { "$ref": "../../entity/data/ontology.json#/properties/prefixes" - }, - "imports": { - "$ref": "../../entity/data/ontology.json#/properties/imports" } }, "required": [ 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 12ee94268839..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 @@ -48,9 +48,6 @@ "owners": { "$ref": "../../type/entityReferenceList.json" }, - "reviewers": { - "$ref": "../../type/entityReferenceList.json" - }, "provider": { "$ref": "../../type/basic.json#/definitions/providerType" } 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 index 2394ea2a29aa..b8ff34e04416 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyConcept.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyConcept.json @@ -21,9 +21,6 @@ "owners": { "$ref": "../../entity/data/ontologyConcept.json#/properties/owners" }, - "reviewers": { - "$ref": "../../entity/data/ontologyConcept.json#/properties/reviewers" - }, "provider": { "$ref": "../../entity/data/ontologyConcept.json#/properties/provider" }, 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 index b87f7f413012..90d2ea9b6a49 100644 --- a/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyProperty.json +++ b/openmetadata-spec/src/main/resources/json/schema/api/data/createOntologyProperty.json @@ -21,9 +21,6 @@ "owners": { "$ref": "../../entity/data/ontologyProperty.json#/properties/owners" }, - "reviewers": { - "$ref": "../../entity/data/ontologyProperty.json#/properties/reviewers" - }, "provider": { "$ref": "../../entity/data/ontologyProperty.json#/properties/provider" }, 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 index 2c47236b4e50..e651008e19b7 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/ontology.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontology.json @@ -78,15 +78,6 @@ "$ref": "../../type/ontologyConfiguration.json#/definitions/prefix" }, "default": [] - }, - "imports": { - "description": "Cross-ontology imports are not supported in this release.", - "type": "array", - "items": { - "$ref": "../../type/entityReference.json" - }, - "maxItems": 0, - "default": [] } }, "required": [ 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 fc8f09bcd9ff..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 @@ -72,9 +72,6 @@ "owners": { "$ref": "../../type/entityReferenceList.json" }, - "reviewers": { - "$ref": "../../type/entityReferenceList.json" - }, "version": { "$ref": "../../type/entityHistory.json#/definitions/entityVersion" }, 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 index ad4e4c41bfb7..d4569996e81b 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyConcept.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyConcept.json @@ -29,9 +29,6 @@ "owners": { "$ref": "../../type/entityReferenceList.json" }, - "reviewers": { - "$ref": "../../type/entityReferenceList.json" - }, "version": { "$ref": "../../type/entityHistory.json#/definitions/entityVersion" }, 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 index f2663a74b31c..1b9a2132fb38 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyProperty.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyProperty.json @@ -40,9 +40,6 @@ "owners": { "$ref": "../../type/entityReferenceList.json" }, - "reviewers": { - "$ref": "../../type/entityReferenceList.json" - }, "version": { "$ref": "../../type/entityHistory.json#/definitions/entityVersion" }, 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 index db62d999b3d2..8f4adb9afdf3 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyRelease.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/data/ontologyRelease.json @@ -65,12 +65,6 @@ "entityStatus": { "$ref": "../../type/status.json" }, - "reviewRevision": { - "description": "Server-managed content revision. Every governed edit invalidates the previous review.", - "type": "integer", - "minimum": 1, - "existingJavaType": "java.lang.Long" - }, "reviewAuthor": { "description": "Author of the current governed content revision; assigned by the server.", "type": "string" @@ -102,7 +96,7 @@ ] }, "contentDigest": { - "description": "SHA-256 of the sealed manifest, including ordered member identities, payload digests, and the pinned import closure.", + "description": "SHA-256 of the sealed manifest, including ordered member identities and payload digests.", "type": "string", "pattern": "^[a-f0-9]{64}$" }, diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/policies/accessControl/resourceDescriptor.json b/openmetadata-spec/src/main/resources/json/schema/entity/policies/accessControl/resourceDescriptor.json index 438041a9f972..5cb225950976 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/policies/accessControl/resourceDescriptor.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/policies/accessControl/resourceDescriptor.json @@ -45,7 +45,6 @@ "EditStatus", "EditTags", "EditGlossaryTerms", - "EditOntologyConcepts", "EditTeams", "EditTier", "EditCertification", 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/ontologyReviewTarget.json b/openmetadata-spec/src/main/resources/json/schema/type/ontologyReviewTarget.json index e7b4a45dbf0f..e1156b62b898 100644 --- a/openmetadata-spec/src/main/resources/json/schema/type/ontologyReviewTarget.json +++ b/openmetadata-spec/src/main/resources/json/schema/type/ontologyReviewTarget.json @@ -17,37 +17,21 @@ "description": "Entity-history version submitted for review; reviewers read this frozen release header.", "$ref": "entityHistory.json#/definitions/entityVersion" }, - "reviewRevision": { - "type": "integer", - "minimum": 1, - "existingJavaType": "java.lang.Long" - }, "contentDigest": { - "description": "SHA-256 of ontology-review-v2 canonical content.", + "description": "SHA-256 of the ontology-review-v3 sealed release identity.", "type": "string", "pattern": "^[a-f0-9]{64}$" }, "author": { "type": "string" - }, - "workflowDefinitionVersion": { - "$ref": "entityHistory.json#/definitions/entityVersion" - }, - "workflowDefinitionDigest": { - "description": "SHA-256 of the workflow definition, including its approval policy.", - "type": "string", - "pattern": "^[a-f0-9]{64}$" } }, "required": [ "entityId", "ontologyId", "entityVersion", - "reviewRevision", "contentDigest", - "author", - "workflowDefinitionVersion", - "workflowDefinitionDigest" + "author" ], "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 6985ae97a29a..c1eee42c85a5 100644 --- a/openmetadata-spec/src/main/resources/rdf/ontology/openmetadata.ttl +++ b/openmetadata-spec/src/main/resources/rdf/ontology/openmetadata.ttl @@ -3700,10 +3700,6 @@ om:reviewTask a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "reviewTask" . -om:reviewRevision a rdf:Property ; - om:projectionStatus om:Stored ; - rdfs:label "reviewRevision" . - om:reviewAuthor a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "reviewAuthor" . @@ -4145,6 +4141,14 @@ 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" . @@ -4169,14 +4173,6 @@ om:enumValues a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "enumValues" . -om:imports a rdf:Property ; - om:projectionStatus om:Stored ; - rdfs:label "imports" . - -om:dependencies a rdf:Property ; - om:projectionStatus om:Stored ; - rdfs:label "dependencies" . - om:unit a rdf:Property ; om:projectionStatus om:Stored ; rdfs:label "unit" . 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 index 9b6fdf0dd3d6..5980c59332d6 100644 --- 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 @@ -13,8 +13,10 @@ 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'; @@ -22,7 +24,8 @@ 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 { getWorkerAdminAPIContext, uuid } from '../../utils/common'; +import { fillCodeEditor } from '../../utils/codeEditor'; +import { fullUuid, getWorkerAdminAPIContext, uuid } from '../../utils/common'; import { readGraphEdges, readNodePositions } from '../../utils/ontologyStudio'; test.use({ isolatedUserOptions: { isAdmin: true } }); @@ -104,11 +107,8 @@ const selectOntology = async (page: Page, ontology: Ontology) => { const openPublication = async (page: Page, ontology: Ontology) => { await page.goto('/governance/ontology'); - await expect(page.getByTestId('mode-tab-review')).toHaveText( - 'Publish and version' - ); - await page.getByTestId('mode-tab-review').click(); await selectOntology(page, ontology); + await page.getByTestId('mode-tab-releases').click(); await expect( page.getByRole('button', { name: 'Capture release', exact: true }) ).toBeEnabled(); @@ -433,8 +433,7 @@ test.describe( const [firstRelease] = releases; await author.reload(); - await author.getByTestId('mode-tab-review').click(); - await selectOntology(author, ontology); + await author.getByTestId('mode-tab-releases').click(); await expect(author.getByTestId('ontology-active-release')).toHaveText( 'Active' ); @@ -452,6 +451,27 @@ test.describe( 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}` @@ -526,8 +546,7 @@ test( let ontology: Ontology | undefined; try { await author.goto('/governance/ontology'); - await author.getByTestId('mode-tab-edit').click(); - await author.getByTestId('ontology-add-ontology').click(); + 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 @@ -574,6 +593,7 @@ test( ); 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 @@ -639,8 +659,7 @@ test( expect(updated.displayName).toBe('Customers'); expect(updated.conceptMappings).toEqual([mapping]); await author.reload(); - await author.getByTestId('mode-tab-edit').click(); - await selectOntology(author, ontology); + await author.getByTestId('mode-tab-model').click(); await author .getByRole('button', { name: 'Customers', exact: true }) .click(); @@ -676,7 +695,7 @@ test( expect(withoutMapping.conceptMappings).toEqual([]); expect(withoutMapping.iri).toBe(concept.iri); await expect(mappingEditor).toHaveCount(0); - await author.getByTestId('ontology-modeling-tab-properties').click(); + await author.getByTestId('ontology-model-property').click(); await author.getByTestId('ontology-add-property').click(); const propertyEditor = author.getByTestId('ontology-property-editor'); await propertyEditor @@ -740,9 +759,8 @@ test( expect(savedProperty.iri).toBe(property.iri); expect(savedProperty.enumValues).toEqual(property.enumValues); await author.reload(); - await author.getByTestId('mode-tab-edit').click(); - await selectOntology(author, ontology); - await author.getByTestId('ontology-modeling-tab-properties').click(); + await author.getByTestId('mode-tab-model').click(); + await author.getByTestId('ontology-model-property').click(); await author .getByRole('button', { name: 'Customer status', exact: true }) .click(); @@ -759,11 +777,15 @@ test( }), 'Declare the superclass' ); - await author.getByTestId('ontology-modeling-tab-axioms').click(); + 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'); @@ -785,10 +807,8 @@ test( 'Create the native subclass axiom' ); expect(hierarchy.ontology.id).toBe(ontology.id); - await expect( - author.getByText('Constraint created successfully.', { exact: true }) - ).toBeVisible(); - await author.getByTestId('mode-tab-view').click(); + 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({ @@ -826,3 +846,151 @@ test( } } ); + +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/Entity/Task/TaskTab/TaskTabNew.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx index a739ce13a3ef..4360245096ee 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx @@ -1352,11 +1352,8 @@ describe('TaskTabNew Component', () => { entityId: 'release-id', entityVersion: 0.2, ontologyId: 'ontology-id', - reviewRevision: 1, author: 'author', contentDigest: 'a'.repeat(64), - workflowDefinitionDigest: 'b'.repeat(64), - workflowDefinitionVersion: 1, }, payload: { proposedChanges: { description: 'Captured release' } }, availableTransitions: [ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyAxiomPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyAxiomPanel.test.tsx index 39da3681431c..9ef79388d3f5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyAxiomPanel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyAxiomPanel.test.tsx @@ -11,90 +11,28 @@ * limitations under the License. */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { - AxiomType, - EntityStatus, - ExpressionKind, - Provenance, -} from '../../generated/api/data/createOntologyAxiom'; -import { Glossary } from '../../generated/entity/data/glossary'; -import { - createOntologyAxiom, - validateOntologyAxiom, -} from '../../rest/ontologyAPI'; +import { fireEvent, render, screen } from '@testing-library/react'; import OntologyAxiomPanel from './OntologyAxiomPanel'; -jest.mock('../../rest/ontologyAPI', () => ({ - createOntologyAxiom: jest.fn(), - validateOntologyAxiom: jest.fn(), -})); +jest.mock( + '../governance/ontology/OntologyPicker/OntologyPicker', + () => + ({ onChange }: { onChange: (value: { id: string }) => void }) => + +); +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.axiom', + [OperationType.DeleteAxiom]: 'label.axiom', +}; 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/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 7c86aa05b06a..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 @@ -722,11 +722,8 @@ describe('TaskDetailPanel', () => { entityId: 'release-id', entityVersion: 0.2, ontologyId: 'ontology-id', - reviewRevision: 1, author: 'author', contentDigest: 'a'.repeat(64), - workflowDefinitionDigest: 'b'.repeat(64), - workflowDefinitionVersion: 1, }, availableTransitions: [ { 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 index b9b688fd7d0e..0277ef6f5e19 100644 --- 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 @@ -20,17 +20,19 @@ 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'; + definitionType: 'concept' | 'property' | 'axiom'; load: ( params: ListParams & { ontology?: string }, signal?: AbortSignal @@ -42,6 +44,17 @@ interface OntologyDefinitionListProps { ) => 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, @@ -61,11 +74,10 @@ const OntologyDefinitionList = < Pick >({}); const [selected, setSelected] = useState(); + const [isDeleting, setIsDeleting] = useState(false); const definition = selected === 'new' ? undefined : selected; const permission = useEntityPermissions( - definitionType === 'concept' - ? ResourceEntity.ONTOLOGY_CONCEPT - : ResourceEntity.ONTOLOGY_PROPERTY, + DEFINITION_RESOURCES[definitionType], { id: definition?.id ?? '' }, { enabled: Boolean(definition), @@ -79,7 +91,7 @@ const OntologyDefinitionList = < { ontology: ontology.fullyQualifiedName, limit: 25, - fields: 'owners,reviewers', + fields: 'owners', ...pagination, }, signal @@ -107,6 +119,31 @@ const OntologyDefinitionList = < ); } + 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; @@ -133,6 +170,7 @@ const OntologyDefinitionList = < void page.refetch(); } )} + {renderDelete()} ); }; 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 index 93cd2cdd6ab0..47895261c5b0 100644 --- 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 @@ -10,13 +10,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Box } from '@openmetadata/ui-core-components'; 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 OntologyAxiomPanel from '../../../OntologyExplorer/OntologyAxiomPanel'; +import OntologyDefinitionList from '../OntologyAuthoringWorkspace/OntologyDefinitionList'; +import OntologyAxiomEditor from '../OntologyAxiomEditor/OntologyAxiomEditor'; const OntologyAxiomWorkspace = ({ ontology }: { ontology: Ontology }) => { const { permissions } = usePermissionProvider(); @@ -26,15 +27,20 @@ const OntologyAxiomWorkspace = ({ ontology }: { ontology: Ontology }) => { ); return ( - - {ontology && ( - listOntologyAxioms(ontology.id, params, signal)} + ontology={ontology}> + {(axiom, canEdit, onSaved) => ( + )} - + ); }; 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 index 07e23aea6770..5afedf006d47 100644 --- 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 @@ -22,6 +22,7 @@ import OntologyCreateForm from './OntologyCreateForm'; jest.mock('../../../../rest/axiosClient', () => ({ post: jest.fn(), + patch: jest.fn(), get: jest.fn(), })); jest.mock('../../../../utils/ToastUtils', () => ({ @@ -43,6 +44,56 @@ describe('OntologyCreateForm', () => { 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(); 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 index 99df80deafa4..1b5738a46fd0 100644 --- 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 @@ -20,25 +20,28 @@ import { isAxiosError } from 'axios'; import { Fragment, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { Layer } from '../../../../generated/api/data/createOntology'; import { Ontology } from '../../../../generated/entity/data/ontology'; -import { createOntology } from '../../../../rest/ontologyAPI'; +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) => { @@ -46,16 +49,7 @@ const OntologyCreateForm = ({ const owners = useUserTeamOptions(); const reviewers = useUserTeamOptions(); const form = useForm({ - defaultValues: { - name: '', - displayName: '', - description: '', - iri: '', - namespace: '', - layer: { id: Layer.L1, label: Layer.L1 }, - owners: [], - reviewers: [], - }, + defaultValues: ontologyFormValues(ontology), }); const [isSaving, setIsSaving] = useState(false); const save = async (values: OntologyFormValues) => { @@ -64,7 +58,11 @@ const OntologyCreateForm = ({ } setIsSaving(true); try { - onSaved(await createOntology(ontologyRequest(values, currentUserId))); + onSaved( + ontology + ? await patchOntology(ontology.id, ontologyPatch(ontology, values)) + : await createOntology(ontologyRequest(values, currentUserId)) + ); } catch (error) { showErrorToast( isAxiosError(error) ? error : t('server.unexpected-error') @@ -80,11 +78,15 @@ const OntologyCreateForm = ({ form={form} onSubmit={form.handleSubmit(save)}> - {ontologyFormFields(t, owners, reviewers, !canCreate || isSaving).map( - (field) => ( - {getField(field)} - ) - )} + {ontologyFormFields( + t, + owners, + reviewers, + !canCreate || isSaving, + Boolean(ontology) + ).map((field) => ( + {getField(field)} + ))} 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 index 8fff8f08ce1a..910ec8c2f318 100644 --- 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 @@ -11,15 +11,19 @@ * 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'; @@ -35,6 +39,22 @@ export interface OntologyFormValues { 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 @@ -52,11 +72,37 @@ export const ontologyRequest = ( 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 + disabled: boolean, + editing = false ): FieldProp[] => { const iriRule = (value: string) => !value.trim() || @@ -124,7 +170,10 @@ export const ontologyFormFields = ( return fields.map((field) => ({ ...field, - props: { ...field.props, isDisabled: disabled }, + props: { + ...field.props, + isDisabled: disabled || (editing && ['name', 'iri'].includes(field.name)), + }, rules: { ...field.rules, ...(field.required 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 index d5ccf68421ad..c35b299a89cb 100644 --- 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 @@ -25,7 +25,10 @@ import { buildNativeOntologyGraph } from '../../../../utils/governance/ontology/ import OntologyControlButtons from '../../../OntologyExplorer/OntologyControlButtons'; import { LayoutType } from '../../../OntologyExplorer/OntologyExplorer.constants'; import { OntologyGraphHandle } from '../../../OntologyExplorer/OntologyExplorer.interface'; -import { buildOntologyTreeGroups } from '../../../OntologyExplorer/OntologyStudio.utils'; +import { + buildOntologyTreeGroups, + buildOntologyTreeMetrics, +} from '../../../OntologyExplorer/OntologyStudio.utils'; import OntologyTreeView from '../../../OntologyExplorer/OntologyTreeView'; import OntologyConceptEditor from '../OntologyConceptEditor/OntologyConceptEditor'; @@ -135,7 +138,8 @@ const OntologyGraphView = ({ ontology }: { ontology: Ontology }) => { {surface === 'tree' ? ( setSelectedId(node.id)} onSelect={(node) => setSelectedId(node.id)} /> ) : ( 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 index 2c1b758152e4..e979ff896faf 100644 --- 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 @@ -145,7 +145,11 @@ const OntologyReleaseDetails = ({ {t('label.activate-release')} - + ); }; 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 index bdddfab06c31..a61484db457f 100644 --- 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 @@ -50,11 +50,8 @@ const target: OntologyReviewTarget = { entityId: 'release-id', entityVersion: 0.2, ontologyId: 'ontology-id', - reviewRevision: 1, contentDigest: 'a'.repeat(64), author: 'author', - workflowDefinitionVersion: 1, - workflowDefinitionDigest: 'b'.repeat(64), }; const task: Task = { id: 'task-id', 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..c0abf65f0341 --- /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: 'release-plural', + 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/generated/api/data/createOntology.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createOntology.ts index 42e8a0e67283..9c9d3387e1f5 100644 --- 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 @@ -17,7 +17,6 @@ export interface CreateOntology { description?: string; displayName?: string; - imports?: EntityReference[]; iri: string; layer?: Layer; name: string; @@ -29,12 +28,21 @@ export interface CreateOntology { } /** - * This schema defines the EntityReference type used for referencing an entity. + * 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 EntityReferenceList type used for referencing an entity. + * 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. @@ -82,15 +90,6 @@ export interface EntityReference { type: string; } -/** - * Governance layer of an ontology model. - */ -export enum Layer { - L1 = "L1", - L2 = "L2", - L3 = "L3", -} - /** * A compact IRI prefix and its absolute namespace. */ 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 be7bdc694da5..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 @@ -25,7 +25,6 @@ export interface CreateOntologyAxiom { propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; } 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 899a6541c73a..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. @@ -223,7 +235,6 @@ export interface OntologyAxiom { propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -408,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. @@ -862,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", } /** @@ -1122,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 index 6e2795e84ba0..9d1da6a3cdce 100644 --- 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 @@ -28,11 +28,10 @@ export interface CreateOntologyConcept { /** * Fully qualified name of the owning ontology. */ - ontology: string; - owners?: EntityReference[]; - provider?: ProviderType; - reviewers?: EntityReference[]; - synonyms?: string[]; + ontology: string; + owners?: EntityReference[]; + provider?: ProviderType; + synonyms?: string[]; } /** 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 index 049e44584b24..aeef598303c0 100644 --- 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 @@ -38,7 +38,6 @@ export interface CreateOntologyProperty { propertyChain?: string[]; provider?: ProviderType; range?: string[]; - reviewers?: EntityReference[]; unit?: string; } 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 d5d517a4ecdb..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. @@ -232,7 +244,6 @@ export interface OntologyAxiom { propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -417,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. @@ -871,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", } /** @@ -1131,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 e770c71d453f..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. @@ -216,7 +220,6 @@ export interface OntologyAxiom { propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -401,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. @@ -855,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", } /** @@ -1115,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 69d2b8002b40..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. @@ -225,7 +229,6 @@ export interface OntologyAxiom { propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -410,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. @@ -864,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", } /** @@ -1124,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/ontologyRelationshipSuggestion.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/ontologyRelationshipSuggestion.ts index b693ee31821d..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. @@ -215,7 +219,6 @@ export interface OntologyAxiom { propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -400,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. @@ -854,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", } /** @@ -1114,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 34adb96afdd3..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. @@ -224,7 +228,6 @@ export interface OntologyAxiom { propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -409,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. @@ -863,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", } /** @@ -1123,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/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/updateOntologyChangeSet.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/updateOntologyChangeSet.ts index 5df3b5869973..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. @@ -222,7 +226,6 @@ export interface OntologyAxiom { propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -407,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. @@ -861,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", } /** @@ -1121,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/api/policies/createPolicy.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/policies/createPolicy.ts index f9f2032eaf4e..32bd04242427 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/policies/createPolicy.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/policies/createPolicy.ts @@ -174,7 +174,6 @@ export enum Operation { EditKnowledgePanel = "EditKnowledgePanel", EditLifeCycle = "EditLifeCycle", EditLineage = "EditLineage", - EditOntologyConcepts = "EditOntologyConcepts", EditOwners = "EditOwners", EditPage = "EditPage", EditPolicy = "EditPolicy", 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 index 4f9e1aca2f22..44597b625ae3 100644 --- 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 @@ -25,11 +25,7 @@ export interface Ontology { /** * Bot user that performed the action on behalf of the actual user. */ - impersonatedBy?: string; - /** - * Cross-ontology imports are not supported in this release. - */ - imports?: EntityReference[]; + impersonatedBy?: string; incrementalChangeDescription?: ChangeDescription; /** * Stable semantic identity of this ontology; independent of its name and catalog record IRI. @@ -114,12 +110,21 @@ export interface FieldChange { } /** - * This schema defines the EntityReference type used for referencing an entity. + * 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 EntityReferenceList type used for referencing an entity. + * 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. @@ -167,15 +172,6 @@ export interface EntityReference { type: string; } -/** - * Governance layer of an ontology model. - */ -export enum Layer { - L1 = "L1", - L2 = "L2", - L3 = "L3", -} - /** * A compact IRI prefix and its absolute namespace. */ 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 4ac59a1e2312..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 @@ -39,7 +39,6 @@ export interface OntologyAxiom { propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; 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 3faddee6f805..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. */ @@ -337,7 +352,6 @@ export interface OntologyAxiom { propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -457,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. @@ -911,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", } /** @@ -1171,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 index eec4e0803ffa..bfe61b4ab2ea 100644 --- 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 @@ -37,10 +37,9 @@ export interface OntologyConcept { /** * Owning ontology entity. Glossaries do not own ontology definitions. */ - ontology: EntityReference; - owners?: EntityReference[]; - provider?: ProviderType; - reviewers?: EntityReference[]; + ontology: EntityReference; + owners?: EntityReference[]; + provider?: ProviderType; /** * Direct named subclasses, derived from non-deleted ontology-owned SUBCLASS_OF axioms. Not * stored or independently editable. 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 index c016db672a33..dabd1f40134b 100644 --- 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 @@ -69,8 +69,7 @@ export interface OntologyProperty { /** * Semantic IRIs of range classes or datatypes. */ - range?: string[]; - reviewers?: EntityReference[]; + range?: string[]; /** * Unit IRI or display symbol for a DATA property, retained as an annotation without * implying conversion. 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 index 8fb0c8dcdf01..8f761c336a97 100644 --- 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 @@ -21,8 +21,7 @@ export interface OntologyRelease { baseReleaseId?: string; changeDescription?: ChangeDescription; /** - * SHA-256 of the sealed manifest, including ordered member identities, payload digests, and - * the pinned import closure. + * SHA-256 of the sealed manifest, including ordered member identities and payload digests. */ contentDigest: string; deleted?: boolean; @@ -58,10 +57,6 @@ export interface OntologyRelease { */ reviewAuthor?: string; reviewers?: EntityReference[]; - /** - * Server-managed content revision. Every governed edit invalidates the previous review. - */ - reviewRevision?: number; /** * Current approval task, assigned by the existing workflow infrastructure. Clients cannot * set this field. diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourceDescriptor.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourceDescriptor.ts index ec7ea2ceefa5..87b401afaf0b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourceDescriptor.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourceDescriptor.ts @@ -56,7 +56,6 @@ export enum Operation { EditKnowledgePanel = "EditKnowledgePanel", EditLifeCycle = "EditLifeCycle", EditLineage = "EditLineage", - EditOntologyConcepts = "EditOntologyConcepts", EditOwners = "EditOwners", EditPage = "EditPage", EditPolicy = "EditPolicy", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourcePermission.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourcePermission.ts index 5128d74d431e..c2cea8f4475a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourcePermission.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourcePermission.ts @@ -98,7 +98,6 @@ export enum Operation { EditKnowledgePanel = "EditKnowledgePanel", EditLifeCycle = "EditLifeCycle", EditLineage = "EditLineage", - EditOntologyConcepts = "EditOntologyConcepts", EditOwners = "EditOwners", EditPage = "EditPage", EditPolicy = "EditPolicy", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/rule.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/rule.ts index 170a198265ba..568b2588db40 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/rule.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/rule.ts @@ -81,7 +81,6 @@ export enum Operation { EditKnowledgePanel = "EditKnowledgePanel", EditLifeCycle = "EditLifeCycle", EditLineage = "EditLineage", - EditOntologyConcepts = "EditOntologyConcepts", EditOwners = "EditOwners", EditPage = "EditPage", EditPolicy = "EditPolicy", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/policy.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/policy.ts index f2f2ee9003f0..bccb58e855d2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/policy.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/policy.ts @@ -326,7 +326,6 @@ export enum Operation { EditKnowledgePanel = "EditKnowledgePanel", EditLifeCycle = "EditLifeCycle", EditLineage = "EditLineage", - EditOntologyConcepts = "EditOntologyConcepts", EditOwners = "EditOwners", EditPage = "EditPage", EditPolicy = "EditPolicy", 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 aec3538980bf..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 @@ -498,7 +498,7 @@ export enum SyncStatus { export interface OntologyReviewTarget { author: string; /** - * SHA-256 of ontology-review-v2 canonical content. + * SHA-256 of the ontology-review-v3 sealed release identity. */ contentDigest: string; entityId: string; @@ -509,13 +509,7 @@ export interface OntologyReviewTarget { /** * Owning ontology ID used to serialize release review decisions. */ - ontologyId: string; - reviewRevision: number; - /** - * SHA-256 of the workflow definition, including its approval policy. - */ - workflowDefinitionDigest: string; - workflowDefinitionVersion: number; + ontologyId: string; } /** 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 f061f81893fb..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. @@ -205,7 +209,6 @@ export interface OntologyAxiom { propertyIri?: string; provenance: Provenance; provider?: ProviderType; - reviewers?: EntityReference[]; subjectIri: string; targetIri?: string; updatedAt?: number; @@ -390,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. @@ -844,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", } /** @@ -1104,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/ontologyReleaseEvent.ts b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReleaseEvent.ts index a1a131de33e8..9c05dca7104a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReleaseEvent.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReleaseEvent.ts @@ -147,7 +147,7 @@ export enum ResolutionType { export interface OntologyReviewTarget { author: string; /** - * SHA-256 of ontology-review-v2 canonical content. + * SHA-256 of the ontology-review-v3 sealed release identity. */ contentDigest: string; entityId: string; @@ -158,11 +158,5 @@ export interface OntologyReviewTarget { /** * Owning ontology ID used to serialize release review decisions. */ - ontologyId: string; - reviewRevision: number; - /** - * SHA-256 of the workflow definition, including its approval policy. - */ - workflowDefinitionDigest: string; - workflowDefinitionVersion: number; + ontologyId: string; } 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 index 7196bf4fe2d8..e3cf0d119696 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReviewTarget.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/type/ontologyReviewTarget.ts @@ -16,7 +16,7 @@ export interface OntologyReviewTarget { author: string; /** - * SHA-256 of ontology-review-v2 canonical content. + * SHA-256 of the ontology-review-v3 sealed release identity. */ contentDigest: string; entityId: string; @@ -27,11 +27,5 @@ export interface OntologyReviewTarget { /** * Owning ontology ID used to serialize release review decisions. */ - ontologyId: string; - reviewRevision: number; - /** - * SHA-256 of the workflow definition, including its approval policy. - */ - workflowDefinitionDigest: string; - workflowDefinitionVersion: number; + ontologyId: string; } 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 8bd17d3efcc5..edf3246868b8 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 @@ -3593,7 +3593,6 @@ "unprocessed": "غير معالج", "unreadable-event": "حدث غير قابل للقراءة", "unrelated": "غير مرتبط", - "unresolved": "Unresolved", "unsaved": "غير محفوظ", "unsaved-changes": "تغييرات غير محفوظة", "unsupported": "غير مدعوم", @@ -5325,9 +5324,8 @@ "ontology-ai-relationship-scope-empty": "أضف مفهومين على الأقل ونوع علاقة واحدًا قبل طلب الاقتراحات.", "ontology-ai-select-glossary": "حدد مسرد مصطلحات واحدًا لتقييد نطاق اقتراحات الذكاء الاصطناعي والتخويل.", "ontology-ai-standards-placeholder": "أدخل معايير مفصولة بفواصل، مثل FIBO وFHIR وschema.org", - "ontology-edit-held-by": "يقوم {{holder}} بتحرير هذا المسرد في جلسة أخرى. أغلق تلك الجلسة أو انتظر دقيقة حتى تنتهي صلاحيتها، ثم حاول مرة أخرى.", - "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "ontology-consistency-not-run": "لم يتم التحقق من الاتساق المنطقي لهذا الإصدار.", + "ontology-edit-held-by": "يقوم {{holder}} بتحرير هذا المسرد في جلسة أخرى. أغلق تلك الجلسة أو انتظر دقيقة حتى تنتهي صلاحيتها، ثم حاول مرة أخرى.", "ontology-empty-description": "تلتقط الأنطولوجيا العلاقات بين مصطلحاتك — ما ينتمي إلى ماذا، وما يُشتق من ماذا — بحيث تصبح مفرداتك نموذجًا مترابطًا لعملك، وليست مجرد قائمة أبجدية.", "ontology-empty-title": "حوّل مسردك إلى خريطة توضح كيفية ترابط المفاهيم", "ontology-format-csv-description": "تنقّل ثنائي الاتجاه للمصطلحات والعلاقات", @@ -5339,8 +5337,8 @@ "ontology-import-issues-count": "تم تخطي {{count}} عنصر أو أنها تحتاج إلى مراجعة", "ontology-import-summary": "تم العثور على {{terms}} مصطلح، و{{relations}} علاقة، و{{mappings}} تعيين مفاهيم، و{{properties}} خاصية مخصصة.", "ontology-imported-successfully": "تم استيراد الأنطولوجيا بنجاح", - "ontology-library-description": "أنطولوجيات جاهزة ومتوافقة مع المعايير. ثبّتها كما هي، أو اختر الوحدات أولاً.", "ontology-iri-per-line": "معرّف IRI واحد في كل سطر؛ يُحفظ ترتيب سلسلة الخصائص.", + "ontology-library-description": "أنطولوجيات جاهزة ومتوافقة مع المعايير. ثبّتها كما هي، أو اختر الوحدات أولاً.", "ontology-library-load-error": "تعذّر تحميل مكتبة الأنطولوجيا.", "ontology-not-connected-description": "لا توجد علاقات لهذه المصطلحات بعد. اربطها لوضعها في الأنطولوجيا.", "ontology-pack-dry-run-success": "اكتمل التشغيل التجريبي", @@ -5361,9 +5359,8 @@ "ontology-relation-siblings": "أشقاء · كلاهما تحت {{term}}", "ontology-relation-subtype": "{{from}} هو {{to}}", "ontology-relationships-feature-description": "اربط المصطلحات بمعنى: «مرتبط بـ» أو «جزء من» أو أنواعك الخاصة مثل «محسوب من».", - "ontology-studio-description": "نمذجة مفاهيم الأعمال وكيفية ارتباطها ببعضها.", - "ontology-remove-reused-name": "أزل المفهوم المحفوظ أولاً لتعيين مفهوم مختلف بالاسم نفسه.", "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.", "ontology-unlocks-feature-description": "تتبّع أي مصطلح حتى أصوله وأجزائه وتبعياته — سياق كامل، وليس تعريفًا منعزلًا.", 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 9c0ed8bc83d7..739bac521666 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 @@ -3593,7 +3593,6 @@ "unprocessed": "Unverarbeitet", "unreadable-event": "Nicht lesbares Ereignis", "unrelated": "Ohne Bezug", - "unresolved": "Unresolved", "unsaved": "Nicht gespeichert", "unsaved-changes": "Nicht gespeicherte Änderungen", "unsupported": "Nicht unterstützt", @@ -5325,9 +5324,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "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", "ontology-format-csv-description": "Bidirektionaler Austausch für Begriffe & Beziehungen", @@ -5339,8 +5337,8 @@ "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-library-description": "Vorgefertigte, standardkonforme Ontologien. Unverändert installieren oder zuerst Module auswählen.", "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.", "ontology-pack-dry-run-success": "Probelauf abgeschlossen", @@ -5361,9 +5359,8 @@ "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-studio-description": "Modellieren Sie Geschäftskonzepte und ihre Beziehungen zueinander.", - "ontology-remove-reused-name": "Entfernen Sie zuerst das gespeicherte Konzept, um ein anderes Konzept mit demselben Namen zuzuweisen.", "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.", "ontology-unlocks-feature-description": "Verfolgen Sie jeden Begriff bis zu seinen übergeordneten Begriffen, Bestandteilen und Abhängigkeiten – vollständiger Kontext, nicht nur eine isolierte Definition.", 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 be6bb9feb07b..0f2d50c5afce 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 @@ -3593,7 +3593,6 @@ "unprocessed": "Unprocessed", "unreadable-event": "Unreadable Event", "unrelated": "Unrelated", - "unresolved": "Unresolved", "unsaved": "Unsaved", "unsaved-changes": "Unsaved changes", "unsupported": "Unsupported", @@ -5325,9 +5324,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "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", "ontology-format-csv-description": "Bulk round-trip for terms & relations", @@ -5361,9 +5359,8 @@ "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-studio-description": "Model business concepts and how they relate.", - "ontology-remove-reused-name": "Remove the saved concept first to assign a different concept with the same name.", "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.", "ontology-unlocks-feature-description": "Follow any term to its parents, parts, and dependencies — full context, not a lone definition.", 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 53fb97375b30..0d4e6805950f 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 @@ -3593,7 +3593,6 @@ "unprocessed": "No Procesado", "unreadable-event": "Evento ilegible", "unrelated": "No Relacionado", - "unresolved": "Unresolved", "unsaved": "Sin guardar", "unsaved-changes": "Cambios sin guardar", "unsupported": "No compatible", @@ -5325,9 +5324,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "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", "ontology-format-csv-description": "Ida y vuelta masiva para términos y relaciones", @@ -5339,8 +5337,8 @@ "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-library-description": "Ontologías predefinidas y alineadas con estándares. Instálalas tal cual o elige primero los módulos.", "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.", "ontology-pack-dry-run-success": "Ejecución de prueba completada", @@ -5361,9 +5359,8 @@ "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-studio-description": "Modela conceptos de negocio y cómo se relacionan.", - "ontology-remove-reused-name": "Elimina primero el concepto guardado para asignar otro concepto con el mismo nombre.", "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.", "ontology-unlocks-feature-description": "Sigue cualquier término hasta sus padres, partes y dependencias: contexto completo, no una definición aislada.", 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 510e1587ad6e..f003056f7e0c 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 @@ -3593,7 +3593,6 @@ "unprocessed": "Non Traité", "unreadable-event": "Événement illisible", "unrelated": "Non Lié", - "unresolved": "Unresolved", "unsaved": "Non enregistré", "unsaved-changes": "Modifications non enregistrées", "unsupported": "Non pris en charge", @@ -5325,9 +5324,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "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", "ontology-format-csv-description": "Aller-retour groupé pour les termes et relations", @@ -5339,8 +5337,8 @@ "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-library-description": "Ontologies prêtes à l'emploi, conformes aux standards. Installez-les telles quelles ou choisissez d'abord les modules.", "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.", "ontology-pack-dry-run-success": "Exécution à blanc terminée", @@ -5361,9 +5359,8 @@ "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-studio-description": "Modélisez les concepts métier et leurs relations.", - "ontology-remove-reused-name": "Supprimez d’abord le concept enregistré pour attribuer un autre concept portant le même nom.", "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.", "ontology-unlocks-feature-description": "Suivez n'importe quel terme jusqu'à ses parents, ses composants et ses dépendances : un contexte complet, pas une définition isolée.", 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 7073855ce542..3ebb469913ee 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 @@ -3593,7 +3593,6 @@ "unprocessed": "Sen procesar", "unreadable-event": "Evento ilexible", "unrelated": "Non relacionado", - "unresolved": "Unresolved", "unsaved": "Sen gardar", "unsaved-changes": "Cambios sen gardar", "unsupported": "Non compatible", @@ -5325,9 +5324,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "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", "ontology-format-csv-description": "Ida e volta masiva para termos e relacións", @@ -5339,8 +5337,8 @@ "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-library-description": "Ontoloxías predefinidas e aliñadas con estándares. Instálaas tal cal ou escolle primeiro os módulos.", "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.", "ontology-pack-dry-run-success": "Execución de proba completada", @@ -5361,9 +5359,8 @@ "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-studio-description": "Modela conceptos de negocio e como se relacionan.", - "ontology-remove-reused-name": "Elimina primeiro o concepto gardado para asignar outro concepto co mesmo nome.", "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.", "ontology-unlocks-feature-description": "Segue calquera termo ata os seus pais, partes e dependencias: contexto completo, non unha definición illada.", 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 318bd04f25c8..c281e2f93ac2 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 @@ -3593,7 +3593,6 @@ "unprocessed": "לא מעובד", "unreadable-event": "אירוע שאינו קריא", "unrelated": "לא קשור", - "unresolved": "Unresolved", "unsaved": "לא שמור", "unsaved-changes": "שינויים שלא נשמרו", "unsupported": "לא נתמך", @@ -5325,9 +5324,8 @@ "ontology-ai-relationship-scope-empty": "הוסיפו לפחות שני מושגים וסוג קשר אחד לפני בקשת הצעות.", "ontology-ai-select-glossary": "בחרו מילון מונחים אחד כדי להגדיר את היקף הצעות הבינה המלאכותית וההרשאה.", "ontology-ai-standards-placeholder": "הזינו תקנים מופרדים בפסיקים, לדוגמה FIBO,‏ FHIR,‏ schema.org", - "ontology-edit-held-by": "{{holder}} עורך את המילון הזה בהפעלה אחרת. סגרו את ההפעלה או המתינו דקה עד שתפוג, ונסו שוב.", - "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "ontology-consistency-not-run": "העקביות הלוגית של גרסה זו טרם נבדקה.", + "ontology-edit-held-by": "{{holder}} עורך את המילון הזה בהפעלה אחרת. סגרו את ההפעלה או המתינו דקה עד שתפוג, ונסו שוב.", "ontology-empty-description": "אונטולוגיה לוכדת את הקשרים בין המונחים שלך — מה שייך למה, מה נגזר ממה — כך שאוצר המילים שלך הופך למודל מקושר של העסק שלך, ולא רק לרשימה אלפביתית.", "ontology-empty-title": "הפוך את מילון המונחים שלך למפה של האופן שבו מושגים מתחברים", "ontology-format-csv-description": "הלוך ושוב מרוכז למונחים ולקשרים", @@ -5339,8 +5337,8 @@ "ontology-import-issues-count": "{{count}} פריטים דולגו או דורשים בדיקה", "ontology-import-summary": "נמצאו {{terms}} מונחים, {{relations}} קשרים, {{mappings}} מיפויי מושגים ו-{{properties}} מאפיינים מותאמים אישית.", "ontology-imported-successfully": "האונטולוגיה יובאה בהצלחה", - "ontology-library-description": "אונטולוגיות מוכנות מראש התואמות לתקנים. התקינו כפי שהן, או בחרו תחילה מודולים.", "ontology-iri-per-line": "IRI אחד בכל שורה; סדר שרשרת המאפיינים נשמר.", + "ontology-library-description": "אונטולוגיות מוכנות מראש התואמות לתקנים. התקינו כפי שהן, או בחרו תחילה מודולים.", "ontology-library-load-error": "לא ניתן לטעון את ספריית האונטולוגיות.", "ontology-not-connected-description": "למונחים אלה אין עדיין קשרים. חברו אותם כדי למקם אותם באונטולוגיה.", "ontology-pack-dry-run-success": "הרצת הניסיון הושלמה", @@ -5361,9 +5359,8 @@ "ontology-relation-siblings": "אחאים · שניהם תחת {{term}}", "ontology-relation-subtype": "{{from}} הוא {{to}}", "ontology-relationships-feature-description": "קשר מונחים במשמעות: \"קשור ל\", \"חלק מ\" או סוגים משלך כמו \"מחושב מ\".", - "ontology-studio-description": "מדלו מושגים עסקיים ואת הקשרים ביניהם.", - "ontology-remove-reused-name": "יש להסיר תחילה את המושג השמור כדי לשייך מושג אחר בעל אותו שם.", "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.", "ontology-unlocks-feature-description": "עקוב אחר כל מונח עד להורים, לחלקים ולתלויות שלו — הקשר מלא, לא הגדרה בודדת.", 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 0a296e793361..c36a207f91c9 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 @@ -3593,7 +3593,6 @@ "unprocessed": "未処理", "unreadable-event": "読み取れないイベント", "unrelated": "関連なし", - "unresolved": "Unresolved", "unsaved": "未保存", "unsaved-changes": "未保存の変更", "unsupported": "サポート対象外", @@ -5325,9 +5324,8 @@ "ontology-ai-relationship-scope-empty": "提案を依頼する前に、少なくとも 2 つの概念と 1 つの関係タイプを追加してください。", "ontology-ai-select-glossary": "AI 提案と認可の範囲を限定する用語集を 1 つ選択してください。", "ontology-ai-standards-placeholder": "標準をカンマ区切りで入力してください(例: FIBO, FHIR, schema.org)", - "ontology-edit-held-by": "{{holder}} が別のセッションでこの用語集を編集しています。そのセッションを閉じるか、期限切れになるまで 1 分ほど待ってから再試行してください。", - "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "ontology-consistency-not-run": "このリリースの論理的整合性は確認されていません。", + "ontology-edit-held-by": "{{holder}} が別のセッションでこの用語集を編集しています。そのセッションを閉じるか、期限切れになるまで 1 分ほど待ってから再試行してください。", "ontology-empty-description": "オントロジーは用語間の関係(何が何に属し、何が何から派生するか)を捉え、語彙を単なる五十音順のリストではなく、ビジネスのつながったモデルにします。", "ontology-empty-title": "用語集を、概念のつながりを示すマップに変えましょう", "ontology-format-csv-description": "用語と関係の一括ラウンドトリップ", @@ -5339,8 +5337,8 @@ "ontology-import-issues-count": "{{count}} 件の項目がスキップされたか、確認が必要です", "ontology-import-summary": "{{terms}} 件の用語、{{relations}} 件の関係、{{mappings}} 件の概念マッピング、{{properties}} 件のカスタムプロパティが見つかりました。", "ontology-imported-successfully": "オントロジーが正常にインポートされました", - "ontology-library-description": "標準に準拠した構築済みオントロジー。そのままインストールするか、先にモジュールを選択してください。", "ontology-iri-per-line": "1行に1つのIRIを入力してください。プロパティチェーンの順序は保持されます。", + "ontology-library-description": "標準に準拠した構築済みオントロジー。そのままインストールするか、先にモジュールを選択してください。", "ontology-library-load-error": "オントロジーライブラリを読み込めませんでした。", "ontology-not-connected-description": "これらの用語にはまだリレーションがありません。接続してオントロジーに配置してください。", "ontology-pack-dry-run-success": "ドライランが完了しました", @@ -5361,9 +5359,8 @@ "ontology-relation-siblings": "兄弟 · 両方とも {{term}} の下", "ontology-relation-subtype": "{{from}} は {{to}} の一種", "ontology-relationships-feature-description": "「関連する」「〜の一部」など意味を持たせて用語をリンクしたり、「〜から算出」のような独自のタイプを作成できます。", - "ontology-studio-description": "ビジネス概念とその関係をモデル化します。", - "ontology-remove-reused-name": "同じ名前の別の概念を割り当てるには、保存済みの概念を先に削除してください。", "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.", "ontology-unlocks-feature-description": "任意の用語からその親・構成要素・依存関係までたどれます。孤立した定義ではなく、完全なコンテキストが得られます。", 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 7aca3bf99b4b..26a44b2ad3e2 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 @@ -3593,7 +3593,6 @@ "unprocessed": "처리되지 않음", "unreadable-event": "읽을 수 없는 이벤트", "unrelated": "관련 없음", - "unresolved": "Unresolved", "unsaved": "저장되지 않음", "unsaved-changes": "저장되지 않은 변경 사항", "unsupported": "지원되지 않음", @@ -5325,9 +5324,8 @@ "ontology-ai-relationship-scope-empty": "제안을 요청하기 전에 개념 두 개 이상과 관계 유형 하나를 추가하세요.", "ontology-ai-select-glossary": "AI 제안과 권한 부여 범위를 지정할 용어집 하나를 선택하세요.", "ontology-ai-standards-placeholder": "쉼표로 구분된 표준을 입력하세요. 예: FIBO, FHIR, schema.org", - "ontology-edit-held-by": "{{holder}}님이 다른 세션에서 이 용어집을 편집하고 있습니다. 해당 세션을 닫거나 만료될 때까지 1분 정도 기다린 후 다시 시도하세요.", - "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "ontology-consistency-not-run": "이 릴리스의 논리적 일관성은 확인되지 않았습니다.", + "ontology-edit-held-by": "{{holder}}님이 다른 세션에서 이 용어집을 편집하고 있습니다. 해당 세션을 닫거나 만료될 때까지 1분 정도 기다린 후 다시 시도하세요.", "ontology-empty-description": "온톨로지는 용어 간의 관계(무엇이 무엇에 속하고, 무엇이 무엇에서 파생되는지)를 포착하여 어휘를 단순한 알파벳순 목록이 아니라 비즈니스의 연결된 모델로 만듭니다.", "ontology-empty-title": "용어집을 개념이 연결되는 방식의 지도로 바꿔보세요", "ontology-format-csv-description": "용어 및 관계의 대량 왕복", @@ -5339,8 +5337,8 @@ "ontology-import-issues-count": "{{count}}개의 항목을 건너뛰었거나 검토가 필요합니다", "ontology-import-summary": "{{terms}}개의 용어, {{relations}}개의 관계, {{mappings}}개의 개념 매핑, {{properties}}개의 사용자 지정 속성을 찾았습니다.", "ontology-imported-successfully": "온톨로지를 성공적으로 가져왔습니다", - "ontology-library-description": "표준에 맞춘 사전 구축 온톨로지입니다. 그대로 설치하거나 먼저 모듈을 선택하세요.", "ontology-iri-per-line": "한 줄에 하나의 IRI를 입력하세요. 속성 체인 순서는 유지됩니다.", + "ontology-library-description": "표준에 맞춘 사전 구축 온톨로지입니다. 그대로 설치하거나 먼저 모듈을 선택하세요.", "ontology-library-load-error": "온톨로지 라이브러리를 불러올 수 없습니다.", "ontology-not-connected-description": "이 용어들에는 아직 관계가 없습니다. 연결하여 온톨로지에 배치하세요.", "ontology-pack-dry-run-success": "시험 실행 완료", @@ -5361,9 +5359,8 @@ "ontology-relation-siblings": "형제 · 둘 다 {{term}} 아래", "ontology-relation-subtype": "{{from}}은(는) {{to}}의 일종", "ontology-relationships-feature-description": "\"관련됨\", \"~의 일부\"와 같은 의미로 용어를 연결하거나 \"~에서 계산됨\"과 같은 사용자 지정 유형을 만드세요.", - "ontology-studio-description": "비즈니스 개념과 그 관계를 모델링합니다.", - "ontology-remove-reused-name": "같은 이름의 다른 개념을 지정하려면 먼저 저장된 개념을 제거하세요.", "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.", "ontology-unlocks-feature-description": "어떤 용어든 상위 항목, 구성 요소, 종속성까지 따라가 보세요. 단편적인 정의가 아니라 전체 맥락을 얻을 수 있습니다.", 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 e263c1ed2bd9..33278781e59f 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 @@ -3593,7 +3593,6 @@ "unprocessed": "प्रक्रिया न केलेले", "unreadable-event": "वाचता न येणारा इव्हेंट", "unrelated": "असंबंधित", - "unresolved": "Unresolved", "unsaved": "जतन न केलेले", "unsaved-changes": "जतन न केलेले बदल", "unsupported": "असमर्थित", @@ -5325,9 +5324,8 @@ "ontology-ai-relationship-scope-empty": "प्रस्ताव मागण्यापूर्वी किमान दोन संकल्पना आणि एक संबंध प्रकार जोडा.", "ontology-ai-select-glossary": "AI प्रस्ताव आणि अधिकृततेची व्याप्ती ठरवण्यासाठी एक शब्दकोश निवडा.", "ontology-ai-standards-placeholder": "स्वल्पविरामाने विभक्त मानके प्रविष्ट करा, उदाहरणार्थ FIBO, FHIR, schema.org", - "ontology-edit-held-by": "{{holder}} दुसऱ्या सत्रात ही शब्दसूची संपादित करत आहेत. ते सत्र बंद करा किंवा ते कालबाह्य होण्यासाठी एक मिनिट थांबा, नंतर पुन्हा प्रयत्न करा.", - "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "ontology-consistency-not-run": "या प्रकाशनाची तार्किक सुसंगतता तपासलेली नाही.", + "ontology-edit-held-by": "{{holder}} दुसऱ्या सत्रात ही शब्दसूची संपादित करत आहेत. ते सत्र बंद करा किंवा ते कालबाह्य होण्यासाठी एक मिनिट थांबा, नंतर पुन्हा प्रयत्न करा.", "ontology-empty-description": "ऑन्टोलॉजी तुमच्या संज्ञांमधील संबंध टिपते — काय कशाशी संबंधित आहे, काय कशापासून निर्माण होते — जेणेकरून तुमचा शब्दसंग्रह केवळ वर्णानुक्रमे यादी नसून तुमच्या व्यवसायाचे जोडलेले मॉडेल बनतो.", "ontology-empty-title": "तुमचा शब्दकोश संकल्पना कशा जोडल्या जातात याच्या नकाशात रूपांतरित करा", "ontology-format-csv-description": "संज्ञा आणि संबंधांसाठी मोठ्या प्रमाणात राउंड-ट्रिप", @@ -5339,8 +5337,8 @@ "ontology-import-issues-count": "{{count}} घटक वगळले गेले किंवा त्यांचे पुनरावलोकन आवश्यक आहे", "ontology-import-summary": "{{terms}} संज्ञा, {{relations}} संबंध, {{mappings}} संकल्पना मॅपिंग आणि {{properties}} सानुकूल गुणधर्म आढळले.", "ontology-imported-successfully": "ऑन्टोलॉजी यशस्वीरित्या आयात केली", - "ontology-library-description": "पूर्व-निर्मित, मानकांशी सुसंगत ऑन्टोलॉजी. जशास तसे इंस्टॉल करा, किंवा आधी मॉड्यूल निवडा.", "ontology-iri-per-line": "प्रत्येक ओळीत एक IRI; गुणधर्म साखळीचा क्रम जतन केला जातो.", + "ontology-library-description": "पूर्व-निर्मित, मानकांशी सुसंगत ऑन्टोलॉजी. जशास तसे इंस्टॉल करा, किंवा आधी मॉड्यूल निवडा.", "ontology-library-load-error": "ऑन्टोलॉजी लायब्ररी लोड करता आली नाही.", "ontology-not-connected-description": "या संज्ञांना अद्याप कोणतेही संबंध नाहीत. त्यांना ऑन्टोलॉजीमध्ये स्थान देण्यासाठी जोडा.", "ontology-pack-dry-run-success": "चाचणी रन पूर्ण झाला", @@ -5361,9 +5359,8 @@ "ontology-relation-siblings": "भावंडे · दोन्ही {{term}} अंतर्गत", "ontology-relation-subtype": "{{from}} हा एक {{to}} आहे", "ontology-relationships-feature-description": "अर्थासह संज्ञा जोडा: \"शी संबंधित\", \"चा भाग\" किंवा \"पासून गणना केलेले\" यासारखे तुमचे स्वतःचे प्रकार.", - "ontology-studio-description": "व्यवसाय संकल्पना आणि त्यांचे परस्परसंबंध मॉडेल करा.", - "ontology-remove-reused-name": "त्याच नावाची वेगळी संकल्पना नियुक्त करण्यासाठी आधी जतन केलेली संकल्पना काढून टाका.", "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.", "ontology-unlocks-feature-description": "कोणत्याही संज्ञेचा तिच्या मूळ, भाग आणि अवलंबनांपर्यंत मागोवा घ्या — संपूर्ण संदर्भ, एकाकी व्याख्या नाही.", 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 2d43ad98e956..3cbeb6224461 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 @@ -3593,7 +3593,6 @@ "unprocessed": "Onverwerkt", "unreadable-event": "Onleesbare gebeurtenis", "unrelated": "Niet gerelateerd", - "unresolved": "Unresolved", "unsaved": "Niet opgeslagen", "unsaved-changes": "Niet-opgeslagen wijzigingen", "unsupported": "Niet ondersteund", @@ -5325,9 +5324,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "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", "ontology-format-csv-description": "Bulk-heen-en-weer voor termen & relaties", @@ -5339,8 +5337,8 @@ "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-library-description": "Kant-en-klare, op standaarden afgestemde ontologieën. Installeer ze zoals ze zijn, of kies eerst modules.", "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.", "ontology-pack-dry-run-success": "Proefuitvoering voltooid", @@ -5361,9 +5359,8 @@ "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-studio-description": "Modelleer bedrijfsconcepten en hoe ze met elkaar samenhangen.", - "ontology-remove-reused-name": "Verwijder eerst het opgeslagen concept om een ander concept met dezelfde naam toe te wijzen.", "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.", "ontology-unlocks-feature-description": "Volg elke term tot aan de bovenliggende termen, onderdelen en afhankelijkheden: volledige context, geen losse definitie.", 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 e74df2bec685..8692086c4af9 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 @@ -3593,7 +3593,6 @@ "unprocessed": "پردازش‌نشده", "unreadable-event": "رویداد غیرقابل خواندن", "unrelated": "بی‌ارتباط", - "unresolved": "Unresolved", "unsaved": "Não Guardado", "unsaved-changes": "تغییرات ذخیره‌نشده", "unsupported": "پشتیبانی‌نشده", @@ -5325,9 +5324,8 @@ "ontology-ai-relationship-scope-empty": "پیش از درخواست پیشنهاد، دست‌کم دو مفهوم و یک نوع رابطه اضافه کنید.", "ontology-ai-select-glossary": "یک واژه‌نامه را برای تعیین دامنه پیشنهادهای هوش مصنوعی و مجوز انتخاب کنید.", "ontology-ai-standards-placeholder": "استانداردها را با ویرگول جدا کنید، برای نمونه FIBO،‏ FHIR،‏ schema.org", - "ontology-edit-held-by": "{{holder}} در حال ویرایش این واژه‌نامه در نشست دیگری است. آن نشست را ببندید یا یک دقیقه صبر کنید تا منقضی شود و دوباره تلاش کنید.", - "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "ontology-consistency-not-run": "سازگاری منطقی این نسخه بررسی نشده است.", + "ontology-edit-held-by": "{{holder}} در حال ویرایش این واژه‌نامه در نشست دیگری است. آن نشست را ببندید یا یک دقیقه صبر کنید تا منقضی شود و دوباره تلاش کنید.", "ontology-empty-description": "هستان‌شناسی روابط میان اصطلاحات شما را ثبت می‌کند — چه چیزی به چه چیزی تعلق دارد، چه چیزی از چه چیزی مشتق می‌شود — تا واژگان شما به مدلی به‌هم‌پیوسته از کسب‌وکارتان تبدیل شود، نه صرفاً یک فهرست الفبایی.", "ontology-empty-title": "واژه‌نامه خود را به نقشه‌ای از چگونگی ارتباط مفاهیم تبدیل کنید", "ontology-format-csv-description": "رفت‌وبرگشت انبوه برای اصطلاحات و روابط", @@ -5339,8 +5337,8 @@ "ontology-import-issues-count": "{{count}} مورد نادیده گرفته شد یا نیاز به بررسی دارد", "ontology-import-summary": "{{terms}} اصطلاح، {{relations}} رابطه، {{mappings}} نگاشت مفهوم و {{properties}} ویژگی سفارشی یافت شد.", "ontology-imported-successfully": "آنتولوژی با موفقیت وارد شد", - "ontology-library-description": "هستی‌شناسی‌های از پیش ساخته و همسو با استانداردها. همان‌طور که هستند نصب کنید یا ابتدا ماژول‌ها را انتخاب کنید.", "ontology-iri-per-line": "در هر خط یک IRI؛ ترتیب زنجیرهٔ ویژگی‌ها حفظ می‌شود.", + "ontology-library-description": "هستی‌شناسی‌های از پیش ساخته و همسو با استانداردها. همان‌طور که هستند نصب کنید یا ابتدا ماژول‌ها را انتخاب کنید.", "ontology-library-load-error": "کتابخانه هستی‌شناسی بارگیری نشد.", "ontology-not-connected-description": "این اصطلاحات هنوز رابطه‌ای ندارند. آن‌ها را متصل کنید تا در هستی‌شناسی جای بگیرند.", "ontology-pack-dry-run-success": "اجرای آزمایشی کامل شد", @@ -5361,9 +5359,8 @@ "ontology-relation-siblings": "هم‌رده · هر دو زیرمجموعهٔ {{term}}", "ontology-relation-subtype": "{{from}} یک {{to}} است", "ontology-relationships-feature-description": "اصطلاحات را با معنا پیوند دهید: «مرتبط با»، «بخشی از» یا انواع دلخواه خودتان مانند «محاسبه‌شده از».", - "ontology-studio-description": "مفاهیم کسب‌وکار و نحوه ارتباط آن‌ها را مدل‌سازی کنید.", - "ontology-remove-reused-name": "ابتدا مفهوم ذخیره‌شده را حذف کنید تا بتوانید مفهوم دیگری با همان نام اختصاص دهید.", "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.", "ontology-unlocks-feature-description": "هر اصطلاح را تا والدین، اجزا و وابستگی‌هایش دنبال کنید — زمینهٔ کامل، نه یک تعریف تنها.", 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 91146e0fc0b9..c7214f7bf361 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 @@ -3593,7 +3593,6 @@ "unprocessed": "Não processado", "unreadable-event": "Evento ilegível", "unrelated": "Não Relacionado", - "unresolved": "Unresolved", "unsaved": "Não salvo", "unsaved-changes": "Alterações não salvas", "unsupported": "Não suportado", @@ -5325,9 +5324,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "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", "ontology-format-csv-description": "Ida e volta em massa para termos e relações", @@ -5339,8 +5337,8 @@ "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-library-description": "Ontologias prontas e alinhadas a padrões. Instale como estão ou escolha os módulos primeiro.", "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.", "ontology-pack-dry-run-success": "Simulação concluída", @@ -5361,9 +5359,8 @@ "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-studio-description": "Modele conceitos de negócio e como eles se relacionam.", - "ontology-remove-reused-name": "Remova primeiro o conceito salvo para atribuir outro conceito com o mesmo nome.", "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.", "ontology-unlocks-feature-description": "Siga qualquer termo até seus pais, partes e dependências: contexto completo, não uma definição isolada.", 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 1617efa19781..9a790743ca3b 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 @@ -3593,7 +3593,6 @@ "unprocessed": "Não Processado", "unreadable-event": "Evento ilegível", "unrelated": "Não Relacionado", - "unresolved": "Unresolved", "unsaved": "Não guardado", "unsaved-changes": "Alterações não guardadas", "unsupported": "Não suportado", @@ -5325,9 +5324,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "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", "ontology-format-csv-description": "Ida e volta em massa para termos e relações", @@ -5339,8 +5337,8 @@ "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-library-description": "Ontologias pré-construídas e alinhadas com normas. Instale tal como estão ou escolha primeiro os módulos.", "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.", "ontology-pack-dry-run-success": "Execução de teste concluída", @@ -5361,9 +5359,8 @@ "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-studio-description": "Modele conceitos de negócio e a forma como se relacionam.", - "ontology-remove-reused-name": "Remova primeiro o conceito guardado para atribuir outro conceito com o mesmo nome.", "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.", "ontology-unlocks-feature-description": "Siga qualquer termo até aos seus ascendentes, partes e dependências: contexto completo, não uma definição isolada.", 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 25cb3add197b..ec3b127d5da6 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 @@ -3593,7 +3593,6 @@ "unprocessed": "Необработанный", "unreadable-event": "Нечитаемое событие", "unrelated": "Не связан", - "unresolved": "Unresolved", "unsaved": "Не сохранено", "unsaved-changes": "Несохранённые изменения", "unsupported": "Не поддерживается", @@ -5325,9 +5324,8 @@ "ontology-ai-relationship-scope-empty": "Добавьте хотя бы два понятия и один тип связи перед запросом предложений.", "ontology-ai-select-glossary": "Выберите один глоссарий, чтобы ограничить область предложений ИИ и авторизации.", "ontology-ai-standards-placeholder": "Введите стандарты через запятую, например FIBO, FHIR, schema.org", - "ontology-edit-held-by": "{{holder}} редактирует этот глоссарий в другом сеансе. Закройте тот сеанс или подождите минуту, пока он истечёт, и повторите попытку.", - "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "ontology-consistency-not-run": "Логическая согласованность этого выпуска не проверена.", + "ontology-edit-held-by": "{{holder}} редактирует этот глоссарий в другом сеансе. Закройте тот сеанс или подождите минуту, пока он истечёт, и повторите попытку.", "ontology-empty-description": "Онтология фиксирует связи между вашими терминами — что к чему относится, что из чего происходит — так что ваш словарь становится связанной моделью вашего бизнеса, а не просто алфавитным списком.", "ontology-empty-title": "Превратите свой глоссарий в карту связей между понятиями", "ontology-format-csv-description": "Массовый обмен терминами и связями", @@ -5339,8 +5337,8 @@ "ontology-import-issues-count": "{{count}} элемент(ов) пропущено или требует проверки", "ontology-import-summary": "Найдено: {{terms}} термин(ов), {{relations}} связь(ей), {{mappings}} сопоставление(й) понятий и {{properties}} пользовательское(их) свойство(в).", "ontology-imported-successfully": "Онтология успешно импортирована", - "ontology-library-description": "Готовые онтологии, соответствующие стандартам. Установите как есть или сначала выберите модули.", "ontology-iri-per-line": "Один IRI в строке; порядок цепочки свойств сохраняется.", + "ontology-library-description": "Готовые онтологии, соответствующие стандартам. Установите как есть или сначала выберите модули.", "ontology-library-load-error": "Не удалось загрузить библиотеку онтологий.", "ontology-not-connected-description": "У этих терминов пока нет связей. Свяжите их, чтобы разместить в онтологии.", "ontology-pack-dry-run-success": "Пробный запуск завершён", @@ -5361,9 +5359,8 @@ "ontology-relation-siblings": "Одноуровневые · оба под {{term}}", "ontology-relation-subtype": "{{from}} — это {{to}}", "ontology-relationships-feature-description": "Связывайте термины по смыслу: «Связано с», «Часть чего-то» или собственными типами, например «вычисляется из».", - "ontology-studio-description": "Моделируйте бизнес-понятия и связи между ними.", - "ontology-remove-reused-name": "Сначала удалите сохранённое понятие, чтобы назначить другое понятие с тем же именем.", "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.", "ontology-unlocks-feature-description": "Проследите любой термин до его родителей, частей и зависимостей — полный контекст, а не отдельное определение.", 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 f560df555664..870d3e88c201 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 @@ -3593,7 +3593,6 @@ "unprocessed": "Obearbetad", "unreadable-event": "Oläsbar händelse", "unrelated": "Orelaterad", - "unresolved": "Unresolved", "unsaved": "Osparad", "unsaved-changes": "Osparade ändringar", "unsupported": "Stöds inte", @@ -5325,9 +5324,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "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", "ontology-format-csv-description": "Bulk-tur och retur för termer och relationer", @@ -5339,8 +5337,8 @@ "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-library-description": "Färdiga, standardanpassade ontologier. Installera som de är, eller välj moduler först.", "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.", "ontology-pack-dry-run-success": "Testkörningen är klar", @@ -5361,9 +5359,8 @@ "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-studio-description": "Modellera affärsbegrepp och hur de hänger ihop.", - "ontology-remove-reused-name": "Ta först bort det sparade begreppet för att tilldela ett annat begrepp med samma namn.", "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.", "ontology-unlocks-feature-description": "Följ vilken term som helst till dess överordnade, delar och beroenden – full kontext, inte en ensam definition.", 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 ba010abe6047..228f28ada0ad 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 @@ -3593,7 +3593,6 @@ "unprocessed": "ยังไม่ได้ประมวลผล", "unreadable-event": "เหตุการณ์ที่อ่านไม่ได้", "unrelated": "ไม่เกี่ยวข้อง", - "unresolved": "Unresolved", "unsaved": "ยังไม่ได้บันทึก", "unsaved-changes": "การเปลี่ยนแปลงที่ยังไม่บันทึก", "unsupported": "ไม่รองรับ", @@ -5325,9 +5324,8 @@ "ontology-ai-relationship-scope-empty": "เพิ่มแนวคิดอย่างน้อยสองรายการและชนิดความสัมพันธ์หนึ่งชนิดก่อนขอข้อเสนอ", "ontology-ai-select-glossary": "เลือกอภิธานศัพท์หนึ่งรายการเพื่อกำหนดขอบเขตข้อเสนอ AI และการอนุญาต", "ontology-ai-standards-placeholder": "ป้อนมาตรฐานโดยคั่นด้วยจุลภาค เช่น FIBO, FHIR, schema.org", - "ontology-edit-held-by": "{{holder}} กำลังแก้ไขอภิธานศัพท์นี้ในเซสชันอื่น ปิดเซสชันนั้นหรือรอหนึ่งนาทีให้หมดอายุ แล้วลองอีกครั้ง", - "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "ontology-consistency-not-run": "ยังไม่ได้ตรวจสอบความสอดคล้องเชิงตรรกะของรุ่นนี้", + "ontology-edit-held-by": "{{holder}} กำลังแก้ไขอภิธานศัพท์นี้ในเซสชันอื่น ปิดเซสชันนั้นหรือรอหนึ่งนาทีให้หมดอายุ แล้วลองอีกครั้ง", "ontology-empty-description": "ออนโทโลยีจับความสัมพันธ์ระหว่างคำศัพท์ของคุณ — อะไรเป็นของอะไร อะไรมาจากอะไร — เพื่อให้คำศัพท์ของคุณกลายเป็นแบบจำลองที่เชื่อมโยงกันของธุรกิจ ไม่ใช่แค่รายการเรียงตามตัวอักษร", "ontology-empty-title": "เปลี่ยนอภิธานศัพท์ของคุณให้เป็นแผนที่ว่าแนวคิดเชื่อมโยงกันอย่างไร", "ontology-format-csv-description": "การรับส่งข้อมูลจำนวนมากสำหรับคำศัพท์และความสัมพันธ์", @@ -5339,8 +5337,8 @@ "ontology-import-issues-count": "{{count}} รายการถูกข้ามหรือต้องตรวจสอบ", "ontology-import-summary": "พบ {{terms}} คำศัพท์, {{relations}} ความสัมพันธ์, {{mappings}} การแม็ปแนวคิด และ {{properties}} คุณสมบัติที่กำหนดเอง", "ontology-imported-successfully": "นำเข้าออนโทโลยีสำเร็จแล้ว", - "ontology-library-description": "ออนโทโลยีสำเร็จรูปที่สอดคล้องกับมาตรฐาน ติดตั้งได้ทันที หรือเลือกโมดูลก่อน", "ontology-iri-per-line": "หนึ่ง IRI ต่อบรรทัด โดยคงลำดับของสายโซ่คุณสมบัติไว้", + "ontology-library-description": "ออนโทโลยีสำเร็จรูปที่สอดคล้องกับมาตรฐาน ติดตั้งได้ทันที หรือเลือกโมดูลก่อน", "ontology-library-load-error": "ไม่สามารถโหลดคลังออนโทโลยีได้", "ontology-not-connected-description": "คำเหล่านี้ยังไม่มีความสัมพันธ์ เชื่อมต่อเพื่อจัดวางในออนโทโลยี", "ontology-pack-dry-run-success": "ทดลองทำงานเสร็จสมบูรณ์", @@ -5361,9 +5359,8 @@ "ontology-relation-siblings": "พี่น้อง · ทั้งคู่อยู่ภายใต้ {{term}}", "ontology-relation-subtype": "{{from}} เป็น {{to}}", "ontology-relationships-feature-description": "เชื่อมโยงคำศัพท์ด้วยความหมาย: \"เกี่ยวข้องกับ\", \"เป็นส่วนหนึ่งของ\" หรือประเภทของคุณเองเช่น \"คำนวณจาก\"", - "ontology-studio-description": "สร้างแบบจำลองแนวคิดทางธุรกิจและความสัมพันธ์ระหว่างกัน", - "ontology-remove-reused-name": "นำแนวคิดที่บันทึกไว้ออกก่อน เพื่อกำหนดแนวคิดอื่นที่มีชื่อเดียวกัน", "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.", "ontology-unlocks-feature-description": "ติดตามคำศัพท์ใด ๆ ไปยังรายการหลัก ส่วนประกอบ และการพึ่งพา — บริบทที่สมบูรณ์ ไม่ใช่คำนิยามที่โดดเดี่ยว", 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 e889618ac3f3..22516361bd80 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 @@ -3593,7 +3593,6 @@ "unprocessed": "İşlenmemiş", "unreadable-event": "Okunamayan olay", "unrelated": "İlişkisiz", - "unresolved": "Unresolved", "unsaved": "Kaydedilmedi", "unsaved-changes": "Kaydedilmemiş değişiklikler", "unsupported": "Desteklenmiyor", @@ -5325,9 +5324,8 @@ "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-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-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "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", "ontology-format-csv-description": "Terimler ve ilişkiler için toplu gidiş-dönüş", @@ -5339,8 +5337,8 @@ "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-library-description": "Hazır, standartlarla uyumlu ontolojiler. Olduğu gibi yükleyin veya önce modülleri seçin.", "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.", "ontology-pack-dry-run-success": "Deneme çalıştırması tamamlandı", @@ -5361,9 +5359,8 @@ "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-studio-description": "İş kavramlarını ve aralarındaki ilişkileri modelleyin.", - "ontology-remove-reused-name": "Aynı ada sahip farklı bir kavram atamak için önce kayıtlı kavramı kaldırın.", "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.", "ontology-unlocks-feature-description": "Herhangi bir terimi üst öğelerine, parçalarına ve bağımlılıklarına kadar izleyin; tek başına bir tanım değil, tam bağlam.", 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 236a9b7f2273..dd5f501eee67 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 @@ -3593,7 +3593,6 @@ "unprocessed": "未加工的", "unreadable-event": "无法读取的事件", "unrelated": "无关联", - "unresolved": "Unresolved", "unsaved": "未保存", "unsaved-changes": "未保存的更改", "unsupported": "不支持", @@ -5325,9 +5324,8 @@ "ontology-ai-relationship-scope-empty": "请求建议前,请添加至少两个概念和一种关系类型。", "ontology-ai-select-glossary": "选择一个术语表,以限定 AI 建议和授权的范围。", "ontology-ai-standards-placeholder": "输入以逗号分隔的标准,例如 FIBO、FHIR、schema.org", - "ontology-edit-held-by": "{{holder}} 正在另一个会话中编辑此术语表。请关闭该会话或等待一分钟使其过期,然后重试。", - "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "ontology-consistency-not-run": "尚未检查此发布版本的逻辑一致性。", + "ontology-edit-held-by": "{{holder}} 正在另一个会话中编辑此术语表。请关闭该会话或等待一分钟使其过期,然后重试。", "ontology-empty-description": "本体捕获术语之间的关系——什么属于什么、什么派生自什么——使您的词汇成为业务的关联模型,而不仅仅是按字母排序的列表。", "ontology-empty-title": "将您的术语表转化为概念如何关联的地图", "ontology-format-csv-description": "术语与关系的批量往返", @@ -5339,8 +5337,8 @@ "ontology-import-issues-count": "{{count}} 个项目被跳过或需要审查", "ontology-import-summary": "找到 {{terms}} 个术语、{{relations}} 个关系、{{mappings}} 个概念映射和 {{properties}} 个自定义属性。", "ontology-imported-successfully": "本体导入成功", - "ontology-library-description": "预构建、符合标准的本体。可直接安装,或先选择模块。", "ontology-iri-per-line": "每行一个 IRI;属性链的顺序将被保留。", + "ontology-library-description": "预构建、符合标准的本体。可直接安装,或先选择模块。", "ontology-library-load-error": "无法加载本体资源库。", "ontology-not-connected-description": "这些术语还没有任何关系。连接它们以将其放入本体中。", "ontology-pack-dry-run-success": "试运行已完成", @@ -5361,9 +5359,8 @@ "ontology-relation-siblings": "同级 · 均属于 {{term}}", "ontology-relation-subtype": "{{from}} 是一种 {{to}}", "ontology-relationships-feature-description": "用含义关联术语:“相关于”“属于”,或您自定义的类型,例如“计算自”。", - "ontology-studio-description": "为业务概念及其相互关系建模。", - "ontology-remove-reused-name": "请先移除已保存的概念,再分配同名的其他概念。", "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.", "ontology-unlocks-feature-description": "沿着任意术语追溯其父级、组成部分和依赖关系——获得完整的上下文,而非孤立的定义。", 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 63330fb43ece..481c331e539e 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 @@ -3593,7 +3593,6 @@ "unprocessed": "未處理", "unreadable-event": "無法讀取的事件", "unrelated": "無關聯", - "unresolved": "Unresolved", "unsaved": "未儲存", "unsaved-changes": "未儲存的變更", "unsupported": "不支援", @@ -5325,9 +5324,8 @@ "ontology-ai-relationship-scope-empty": "要求建議前,請新增至少兩個概念和一種關係類型。", "ontology-ai-select-glossary": "選取一個詞彙表,以界定 AI 建議和授權的範圍。", "ontology-ai-standards-placeholder": "輸入以逗號分隔的標準,例如 FIBO、FHIR、schema.org", - "ontology-edit-held-by": "{{holder}} 正在另一個工作階段中編輯此詞彙表。請關閉該工作階段或等待一分鐘使其過期,然後再試一次。", - "ontology-annotation-unresolved": "This concept is unavailable in the active approved ontology release. Its saved identity is retained.", "ontology-consistency-not-run": "尚未檢查此發佈版本的邏輯一致性。", + "ontology-edit-held-by": "{{holder}} 正在另一個工作階段中編輯此詞彙表。請關閉該工作階段或等待一分鐘使其過期,然後再試一次。", "ontology-empty-description": "本體論擷取詞彙之間的關係——什麼屬於什麼、什麼衍生自什麼——使您的詞彙成為業務的連結模型,而不僅是按字母排序的清單。", "ontology-empty-title": "將您的詞彙表轉化為概念如何連結的地圖", "ontology-format-csv-description": "詞彙與關聯的大量往返", @@ -5339,8 +5337,8 @@ "ontology-import-issues-count": "{{count}} 個項目被略過或需要審查", "ontology-import-summary": "找到 {{terms}} 個術語、{{relations}} 個關聯、{{mappings}} 個概念對應與 {{properties}} 個自訂屬性。", "ontology-imported-successfully": "本體匯入成功", - "ontology-library-description": "預先建置、符合標準的本體。可直接安裝,或先選擇模組。", "ontology-iri-per-line": "每行一個 IRI;屬性鏈的順序將予以保留。", + "ontology-library-description": "預先建置、符合標準的本體。可直接安裝,或先選擇模組。", "ontology-library-load-error": "無法載入本體資源庫。", "ontology-not-connected-description": "這些術語尚未有任何關係。連接它們以將其放入本體中。", "ontology-pack-dry-run-success": "試執行已完成", @@ -5361,9 +5359,8 @@ "ontology-relation-siblings": "同層級 · 皆屬於 {{term}}", "ontology-relation-subtype": "{{from}} 是一種 {{to}}", "ontology-relationships-feature-description": "用含義連結詞彙:「相關於」「屬於」,或您自訂的類型,例如「計算自」。", - "ontology-studio-description": "為業務概念及其相互關係建模。", - "ontology-remove-reused-name": "請先移除已儲存的概念,再指派同名的其他概念。", "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.", "ontology-unlocks-feature-description": "沿著任一詞彙追溯其父項、組成部分和相依性——取得完整的脈絡,而非孤立的定義。", 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..c70affe56038 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,6 +407,45 @@ const OntologyExplorerPage: React.FC = () => { isLoading: isCapabilityLoading, } = useOntologyAiCapability(); const initialDraftId = useLinkedDraftId(); + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedOntology, setSelectedOntology] = useState(); + const [isCreatingOntology, setIsCreatingOntology] = useState(false); + const ontologyId = searchParams.get('ontology'); + const canCreateOntology = canCreateNativeOntology(isAdminUser, permissions); + const selectOntology = (ontology?: Ontology) => { + setSelectedOntology(ontology); + setIsCreatingOntology(false); + setSearchParams((current) => { + const next = new URLSearchParams(current); + if (ontology) { + next.set('ontology', ontology.id); + } else { + next.delete('ontology'); + } + + return next; + }); + }; + useEffect(() => { + if (!ontologyId) { + setSelectedOntology(undefined); + + return; + } + if (selectedOntology?.id === ontologyId) { + return; + } + const controller = new AbortController(); + getOntology(ontologyId, 'owners,reviewers', controller.signal) + .then(setSelectedOntology) + .catch((error) => { + if (!controller.signal.aborted) { + showErrorToast(error); + } + }); + + return () => controller.abort(); + }, [ontologyId, selectedOntology?.id]); const [mode, setMode] = useState(() => studioModeFor(initialDraftId) ); @@ -667,11 +717,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 +900,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 +1051,25 @@ const OntologyExplorerPage: React.FC = () => { ); } + const renderWorkspace = () => ( + <> + {selectedOntology || isCreatingOntology ? ( + + ) : ( + <> + {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/rest/ontologyAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/ontologyAPI.ts index b46b1fb31b43..41e2811c6fd3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/ontologyAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/ontologyAPI.ts @@ -116,12 +116,24 @@ 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, @@ -135,7 +147,7 @@ export const patchOntologyConcept = async (id: string, patch: Operation[]) => { const response = await APIClient.patch( `${ONTOLOGY_CONCEPTS_PATH}/${encodeURIComponent(id)}`, patch, - { headers: { 'Content-Type': 'application/json-patch+json' } } + { headers: { 'Content-Type': JSON_PATCH_CONTENT_TYPE } } ); return response.data; @@ -156,7 +168,7 @@ export const patchOntologyProperty = async (id: string, patch: Operation[]) => { const response = await APIClient.patch( `${ONTOLOGY_PROPERTIES_PATH}/${encodeURIComponent(id)}`, patch, - { headers: { 'Content-Type': 'application/json-patch+json' } } + { headers: { 'Content-Type': JSON_PATCH_CONTENT_TYPE } } ); return response.data; @@ -444,11 +456,12 @@ export const deleteRelationshipType = async (id: string) => { export const listOntologyAxioms = async ( ontologyId: string, - params?: ListParams + params?: ListParams, + signal?: AbortSignal ) => { const response = await APIClient.get>( ONTOLOGY_AXIOMS_PATH, - { params: { ...params, ontologyId } } + { params: { ...params, ontologyId }, signal } ); return response.data; @@ -472,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/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.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TaskNavigationUtils.ts index 170c7fe71586..72b2c14f39cf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TaskNavigationUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TaskNavigationUtils.ts @@ -98,10 +98,12 @@ export const getEntityTasksPath = ( }; export const getTaskDetailPathFromTask = (task: TaskEntity) => - isOntologyReviewTask(task) ? getTaskDetailPath(task) : 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 From e21b9f98f748c2245c3bda3adb943fd676c5e2f3 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 16 Oct 2026 17:10:06 -0700 Subject: [PATCH 03/10] Fix search integration test cleanup and count reconciliation Remove the orphaned table from the search index as well as SQL and verify cleanup completes. Count soft-deleted entities on both sides of database/search reconciliation and include an explicit deleted-user fixture. All seven affected tests pass on PostgreSQL/OpenSearch. --- .../search/DbToEsCountReconciliationIT.java | 9 +++++++-- .../it/tests/search/ReindexStatsIT.java | 18 +++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) 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())); } } From 4fe9e07cc3a807aa3494ee8602a0bbd0e238aa31 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Fri, 16 Oct 2026 19:19:40 -0700 Subject: [PATCH 04/10] fix: restore native Studio and batch ontology release permissions Restore native graph and scope navigation, release glossary edit leases on native selection, and preserve ordinary task feed actions. Batch current ownership and reviewer loading for release reads and avoid derived hierarchy work during capture. Add native workspace regressions and a PostgreSQL SQL-budget test. Record focused validation and the remaining review decisions in the completion plan. --- docs/generated/api-reference.md | 63 ++++++- ...10-16-native-ontology-studio-completion.md | 28 +++- .../tests/ChangeRequestImportAndBulkIT.java | 5 +- .../IndependentOntologyAuthorizationIT.java | 87 ++++++++++ .../ontology/OntologyReleaseActivation.java | 3 +- .../ontology/OntologyReleaseCapture.java | 10 +- .../ontology/OntologyReleaseComparison.java | 2 +- .../ontology/OntologyReleaseQuery.java | 8 +- .../ontology/OntologyReleaseReader.java | 6 +- .../ontology/OntologyReleaseStructure.java | 3 +- .../service/rdf/OntologyReleaseExporter.java | 3 +- .../ontology/OntologyReleaseResource.java | 15 +- .../ontology/OntologyResourceAccess.java | 41 ++--- .../service/tasks/TaskWorkflowHandler.java | 6 +- .../service/jdbi3/OntologyCaptureFixture.java | 5 +- .../jdbi3/OntologyReleaseActivationTest.java | 6 +- .../OntologyReleaseComparisonStorageTest.java | 6 +- .../TaskFeedCardFromTask.component.test.tsx | 6 +- .../TaskFeedCardFromTask.component.tsx | 14 +- .../TaskTab/TaskTabNew.component.test.tsx | 137 +++++++++------- .../Task/TaskTab/TaskTabNew.component.tsx | 14 +- .../OntologyMemoryReviewPanel.tsx | 4 +- .../OntologyExplorer/utils/graphBuilders.ts | 6 +- .../OntologyDefinitionList.tsx | 5 +- .../OntologyStudioWorkspace.tsx | 2 +- .../ui/src/locale/languages/it-it.json | 17 ++ .../OntologyExplorerPage.test.tsx | 154 +++++++++++++++++- .../OntologyExplorerPage.tsx | 70 +++++--- 28 files changed, 557 insertions(+), 169 deletions(-) create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndependentOntologyAuthorizationIT.java 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/plans/2026-10-16-native-ontology-studio-completion.md b/docs/plans/2026-10-16-native-ontology-studio-completion.md index 43e5d36f577c..a91deaaf6d18 100644 --- a/docs/plans/2026-10-16-native-ontology-studio-completion.md +++ b/docs/plans/2026-10-16-native-ontology-studio-completion.md @@ -1,14 +1,38 @@ # Native ontology completion -The approved scope preserves main's complete glossary Studio and adds native ontologies alongside it. Publication approves a sealed release through the existing workflow; editing a definition does not start another approval lifecycle. Metadata labels remain a separate follow-up. +The approved scope preserves main's glossary Studio and adds native ontologies alongside it. Publication approves a sealed release through the existing workflow; editing a definition does not start another approval lifecycle. Metadata labels remain a separate follow-up. Review 4 asks the product owner to resolve the glossary axiom exception and the native memory producer scope below. 1. Merge `origin/main` at `c2802106708`, retaining its Studio components, page, existing browser specs and `TaskResourceIT`. Reconcile shared task hooks and regenerate locales. Verify conflict markers, formatting and compilation. 2. Fix release durability and review recovery in `service/ontology/OntologyReleaseCapture`, `OntologyReleaseReader`, `OntologyReview`, and `governance/workflows/OntologyReviewReconciler`. Default release comparisons to their captured base. Move ontology DDL before the final database collation change in native `2.1.0`. Verify release, review, migration and activation tests. 3. Extend the change-set schema and existing application service with a native ontology scope. Apply concept/property/axiom operations under `OntologyWorkingState`, preserve optimistic revisions and permissions, and expose those changes in the existing memory review panel. Record the contract in an ADR; regenerate Java, Python and TypeScript models. Verify atomic application, stale drafts, authorization and release capture. 4. Integrate native selection, Model, Releases and active-release Query into main's `OntologyExplorerPage`. Complete ontology/reviewer editing and definition/axiom CRUD in `components/governance/ontology`. Keep the existing glossary graph, AI, library, leases, import/export, query builder, data-explore and workbench. Verify Jest plus restored and native browser specs, including non-admin users. 5. Remove obsolete labels/imports/per-definition review remnants; isolate unrelated SDK, event and test changes. Update the design and capability matrix to distinguish native support from follow-ups. -6. Run affected Java tests and ITs on MySQL and PostgreSQL with real Fuseki, UI lint/type checks, focused Jest and browser tests. Record exact evidence and remaining limits in the session validation artifact. No push. +6. Run affected Java tests and ITs on MySQL and PostgreSQL with real Fuseki, UI lint/type checks, focused Jest and browser tests. Record exact evidence and remaining limits in the session validation artifact. The later request to open a pull request authorized publishing draft PR #35672 and its follow-up fixes. Status: checkpoint `919aaaa9ec6` created; main merged in `25df2e9e919`. Native memory application, authorization, stale drafts, rollback, release capture, review, recovery, comparison and activation pass MySQL and PostgreSQL API tests. The 64 targeted service tests pass, including both database dialects. All four failures from the expanded MySQL run passed focused reruns, alongside 19 restore unit tests. PostgreSQL validation: the affected API/migration run passed 2,200 tests with 181 skips; the isolated run passed 334 with one embedding test skipped for missing Bedrock configuration. The RDF run passed 279 with one optional scale skip and two fixture-collision failures; isolating the replacement graph fixed those failures, and all 12 authorization cases passed the rerun. Native activation, rollback and rebuild passed with real Fuseki. All 22 shared SPARQL tests passed both alone and in the RDF lane after readiness failures in the initial mixed run. The search lane initially passed 68 tests with five errors: four aggregation-count errors shared an orphaned search fixture, reproduced by a minimal sequence on clean main; one user-count error compared non-deleted API users against unfiltered search documents. Both causes are fixed in two test files: cleanup removes the orphan's search document, and reconciliation includes soft-deleted entities on both sides with an explicit deleted-user fixture. All seven tests in the three affected classes passed together on PostgreSQL/OpenSearch, including all five prior errors; the full search selection was not repeated. Browser validation and the remaining affected-test lanes are outstanding. No completion or merge-readiness claim is made yet. + +## Review 4 follow-up + +The native tree recognizes ontology concepts again, and the graph snapshot retains its initialization/destroy guard. Native selection follows the URL, clears an unrelated draft link when switching scopes, and stops the glossary edit lease. Native draft deep links remain supported. Missing UI labels reuse the existing constraint and ontology-release translations. Feed quick actions retain their existing behavior for non-ontology tasks; release reviews still open the review screen. + +Release authorization hydrates current owners and reviewers with the existing repository batch loader, one page at a time, and still authorizes every frozen definition. Capture no longer requests derived hierarchy fields. The existing reviewer inheritance lookup remains in place because the general inheritance loader does not hydrate that relationship. + +Validation for this follow-up: 92 Jest tests pass across seven suites, including the actual native picker/workspace/tree and router transitions. The graph and scope-switch regressions fail against their previous implementations. ESLint and Prettier pass on the changed files. The full typecheck still reports 554 baseline diagnostics, with no new file/error-code pairs; the token audit reports 75 errors in unchanged CSS/LESS files. The first targeted Java run passed 28 service tests, including MySQL and PostgreSQL storage cases. On the final backend, 30 PostgreSQL API tests pass for release capture, read permissions, diff, export and activation. The new SQL-budget IT passes its focused PostgreSQL rerun: capture uses 25 reads and structure uses 23 reads at both 4 and 40 concepts. Its initial counter included conditional member inserts, then a prefix filter missed decorated SQL; the corrected counter excludes only those inserts and asserts it observed reads. Java formatting and impact-map checks pass. These focused results do not replace the outstanding full affected-test run. + +Open decisions: D1 must reconcile native-only axioms and the prerelease reset with preserving glossary axiom authoring and applied change-set history. D2 must settle whether UI creation of native memory drafts and tolerance of unrelated working edits are required before merge. Neither behavior was changed by this follow-up. + +The remaining review findings still need triage and validation: native memory derivation/pagination and conflict handling, query-model materialization costs, archived-draft/review handling, shared workflow/task-storage/rebuild coverage, and browser fixture cleanup. The final browser run/recording and changed-class coverage also remain open. This is a correction checkpoint, not feature completion. + +## Rebase validation + +The branch is rebased onto `origin/main` at `2d95c1a1b9a594415b1a3eb102c0626d03c901e2`. The approval-flow conflicts are resolved: main's change-request and lifecycle handling remain alongside native release review. Workflow actions require an assigned reviewer and, for native reviews, loaded release content. The UI regression covers both an assigned reviewer and an unassigned administrator. Main's Studio changes and the native picker, tree, workspace and memory review are retained. + +The full affected Java matrix ran on the first rebase at `8e5b5fe16b6`: 13,830 unit tests (one skip), MySQL RDF (282 tests, one skip), cache tests (51 tests, one skip), and all four PostgreSQL/OpenSearch groups passed. PostgreSQL ran 2,372 API/migration tests (181 skips), 335 global-state tests (one skip), 282 RDF tests (one skip), and 73 search tests. The first MySQL parallel group reported 26 errors under dynamic host concurrency; the global-state group found one fixture that tried to approve as an unassigned owner. That fixture now explicitly assigns its reviewer. Focused MySQL reruns use CI's four-worker limit. The generated matrix report remains failed and is recorded separately from those reruns. + +The final MySQL rerun passed 773 tests with 70 skips. XML verification confirms all 26 previously failing cases executed and passed, alongside all six cases from three suites initially skipped after K3s started. The corrected approval fixture and system-configuration suite passed all 68 tests without skips. Main's email-domain validation passed 167 security/user unit tests and its additional user API regression. The planner's missing `McpServiceResourceIT` report refers to an upstream TODO-only file with no test class; optional scale, embedding and nightly exclusions remain documented. + +Four main commits arrived during validation: Oracle temporary-table ingestion, stored-procedure sanitization, CSV export selection, and user email-domain validation. The final replay changed exactly their 43 paths, with no overlap with the ontology branch. Models were regenerated, the final UI build passed, and the additional CSV and Python checks passed 60 and 68 tests respectively. The original 89 Jest suites passed 778 tests. Java formatting, UI lint/format/license checks and generation pass. + +The final app typecheck reports 553 diagnostics versus 564 on clean main; the Playwright typecheck reports 115 on each, with no added or increased file/error-code groups. The token audit still reports 86 errors. These baseline failures remain open. No browser E2E, new recording or changed-class coverage measurement is claimed for this rebase. 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/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-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseActivation.java b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseActivation.java index c1dd7b6dd7e2..5407046e42b1 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseActivation.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseActivation.java @@ -3,6 +3,7 @@ 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; @@ -25,7 +26,7 @@ public final class OntologyReleaseActivation { private final Clock clock; private final Consumer enqueue; - public record Access(String user, Consumer authorize) {} + public record Access(String user, Consumer> authorize) {} public OntologyReleaseActivation( final OntologyRepository ontologies, final Clock clock, final Consumer enqueue) { 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 index 6739116d8588..4391ab416c0c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseCapture.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseCapture.java @@ -39,7 +39,7 @@ public final class OntologyReleaseCapture { private final Map> definitions; private final Clock clock; - public record Access(String user, Consumer authorize) {} + public record Access(String user, Consumer> authorize) {} public OntologyReleaseCapture( final OntologyReleaseRepository releases, @@ -73,9 +73,11 @@ private OntologyRelease captureLocked( "Ontology changed; reload its working revision before capture"); } final Ontology ontology = - ontologies.get(null, ontologyId, ontologies.getFields("*"), Include.NON_DELETED, false); + 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); @@ -166,7 +168,8 @@ private void add( do { requireTimeRemaining(); final ResultList page = - repository.listAfter(null, repository.getFields("*"), filter, PAGE_SIZE, after); + 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); @@ -177,7 +180,6 @@ private void add(final MemberKind kind, final OntologyDefinition entity) { if (values.size() >= OntologyReleaseManifest.MAX_MEMBERS) { throw new BadRequestException("An ontology release exceeds the 10000 member limit"); } - access.authorize().accept(entity); final OntologyReleaseMember member = OntologyReleaseManifest.member( releaseId, kind, entity.getId(), OntologyReleaseContent.project(entity)) 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 index c648cf59a0f9..121cce43615a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseComparison.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseComparison.java @@ -30,7 +30,7 @@ public OntologyReleaseDiff compare( final OntologyRelease base, final int limit, final String after, - final Consumer authorize) { + final Consumer> authorize) { if (limit < 1 || limit > 1000) { throw new BadRequestException("A release comparison page requires a limit from 1 to 1000"); } 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 index 8f0b5c2b5caf..d2a7e43b88b1 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseQuery.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseQuery.java @@ -1,5 +1,6 @@ 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; @@ -25,14 +26,17 @@ public OntologyReleaseQuery( } public OntologySparqlQueryService.QueryResult query( - final UUID id, final SparqlQuery request, final Consumer authorize) { + 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) { + 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"); 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 index c2cdbbe47ebc..47b9cd7bb169 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseReader.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseReader.java @@ -26,8 +26,8 @@ public OntologyReleaseReader(final CollectionDAO dao) { } public List read( - final OntologyRelease release, final Consumer authorize) { - authorize.accept(release); + 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()) { @@ -39,10 +39,10 @@ public List read( for (final var member : page) { bytes += member.getPayloadBytes(); requireBounds(members.size() + 1, bytes); - authorize.accept(definition(release, member)); 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); 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 index f9ddf47639c6..7d5799a4d94e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseStructure.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ontology/OntologyReleaseStructure.java @@ -1,5 +1,6 @@ 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; @@ -15,7 +16,7 @@ public OntologyReleaseStructure(final OntologyReleaseRepository releases) { } public OntologyReleaseStructureReport report( - final OntologyRelease release, final Consumer authorize) { + 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/rdf/OntologyReleaseExporter.java b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyReleaseExporter.java index 5bf1a947bcac..7cdebc8c3ea5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyReleaseExporter.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/rdf/OntologyReleaseExporter.java @@ -1,5 +1,6 @@ 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; @@ -18,7 +19,7 @@ public OntologyReleaseExporter(final OntologyReleaseRepository releases) { public String export( final OntologyRelease release, final RdfSerializationFormat format, - final Consumer authorize) { + final Consumer> authorize) { final var closure = reader.read(release, authorize); final Model model = new OntologyReleaseProjection().build(release, closure); try { 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 index 0617a83d6b70..0cfa16741471 100644 --- 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 @@ -15,6 +15,7 @@ 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; @@ -192,7 +193,8 @@ public OntologyReleaseMember getMember( @PathParam("memberId") final UUID memberId) { get(uriInfo, securityContext, id); final OntologyReleaseMember member = repository.getMember(id, kind, memberId); - authorizeMember(securityContext, member); + OntologyResourceAccess.requireView( + authorizer, securityContext, List.of(OntologyReleaseReader.definition(member))); return member; } @@ -211,13 +213,10 @@ public ResultList listMembers( get(uriInfo, securityContext, id); final ResultList members = repository.listMembers(id, kind, limit, after); - members.getData().forEach(member -> authorizeMember(securityContext, member)); - return members; - } - - private void authorizeMember( - final SecurityContext securityContext, final OntologyReleaseMember member) { OntologyResourceAccess.requireView( - authorizer, securityContext, OntologyReleaseReader.definition(member)); + authorizer, + securityContext, + members.getData().stream().map(OntologyReleaseReader::definition).toList()); + return members; } } 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 index 4da29cb48e86..9e844014e629 100644 --- 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 @@ -1,13 +1,12 @@ 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.entity.data.Ontology; -import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; import org.openmetadata.service.Entity; -import org.openmetadata.service.exception.EntityNotFoundException; import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.security.Authorizer; import org.openmetadata.service.security.policyevaluator.OperationContext; @@ -20,32 +19,28 @@ private OntologyResourceAccess() {} static void requireView( final Authorizer authorizer, final SecurityContext context, - final OntologyDefinition definition) { - final String type = definition.getEntityReference().getType(); - requireView(authorizer, context, definition, Entity.getEntityRepository(type)); + 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 OntologyDefinition definition, + final List definitions, final EntityRepository repository) { final String type = repository.getEntityType(); - try { - definition.setOwners( - repository - .get(null, definition.getId(), repository.getFields("owners"), Include.ALL, false) - .getOwners()); - } catch (EntityNotFoundException exception) { - if (definition.getOntology() == null) { - throw exception; - } - final Ontology parent = Entity.getEntity(definition.getOntology(), "owners", Include.ALL); - definition.setOwners(parent.getOwners()); - } - authorizer.authorize( - context, - new OperationContext(type, MetadataOperation.VIEW_ALL), - new ResourceContext<>(type, repository.getEntityClass().cast(definition), repository)); + 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/tasks/TaskWorkflowHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowHandler.java index 7465c45925c8..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 @@ -62,12 +62,8 @@ 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.metadata.MetadataCollectionService; -import org.openmetadata.service.formatter.util.FormatterUtil; -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.TaskResolutionOutcome; +import org.openmetadata.service.governance.workflows.metadata.MetadataCollectionService; import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.jdbi3.TaskRepository; import org.openmetadata.service.jdbi3.WorkflowInstanceRepository; 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 index d718a44b00bc..31a788837422 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyCaptureFixture.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyCaptureFixture.java @@ -88,7 +88,10 @@ OntologyRelease capture(Consumer authorize) { .withExpectedWorkingRevision( ontologies.getState(ontology.getId()).getWorkingRevision()), new OntologyReleaseCapture.Access( - "admin", definition -> authorize.accept(definition.getEntityReference()))); + "admin", + definitions -> + definitions.forEach( + definition -> authorize.accept(definition.getEntityReference())))); lastCaptureId = release.getId(); return release; } 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 index 0412a8d8c39c..8a0f944018d7 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseActivationTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseActivationTest.java @@ -39,8 +39,8 @@ void immutableContentChecksDoNotHoldTheOntologyWriteLock(ConnectionType dialect) final var access = new OntologyReleaseActivation.Access( "admin", - definition -> { - if (definition instanceof OntologyRelease) { + definitions -> { + if (definitions.stream().anyMatch(OntologyRelease.class::isInstance)) { try (final var writer = Executors.newSingleThreadExecutor()) { writer .submit( @@ -54,7 +54,7 @@ void immutableContentChecksDoNotHoldTheOntologyWriteLock(ConnectionType dialect) "/description", "Edited while validating the release") .build())) - .get(3, TimeUnit.SECONDS); + .get(15, TimeUnit.SECONDS); } catch (InterruptedException | ExecutionException | TimeoutException failure) { 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 index 8d0ef5e3acf6..29f6419dfe7b 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseComparisonStorageTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/OntologyReleaseComparisonStorageTest.java @@ -82,8 +82,10 @@ void changesRetainBothSnapshotsAndTheirPermissions(ConnectionType dialect) { previous, 1, null, - definition -> { - if (restricted.equals(definition.getDescription())) { + definitions -> { + if (definitions.stream() + .anyMatch( + definition -> restricted.equals(definition.getDescription()))) { throw new ForbiddenException("Restricted definition"); } })); 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 1c8d6f757fb9..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 @@ -382,13 +382,14 @@ describe('TaskFeedCardFromTask', () => { } ); - it('requires a comment before rejecting a non-ontology approval task', async () => { + it('preserves quick actions for non-ontology approval tasks', async () => { await act(async () => { render( { ); }); - expect(screen.queryByTestId('reject-button')).not.toBeInTheDocument(); + 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 64adc73e8096..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 @@ -256,14 +256,8 @@ const TaskFeedCardFromTask = ({ : transition.id === 'approve' ); - const canQuickResolve = (resolutionType: TaskResolutionType) => { - if (task.status !== TaskEntityStatus.Open || isOntologyReviewTask(task)) { - return false; - } - const transition = getTransition(resolutionType); - - return !transition?.requiresComment; - }; + const canQuickResolve = () => + task.status === TaskEntityStatus.Open && !isOntologyReviewTask(task); const updateTaskData = async ( newValue: string, @@ -392,7 +386,7 @@ const TaskFeedCardFromTask = ({ {!isTaskTestCaseResult && hasEditAccess && !isSuggestionEmpty && ( - {canQuickResolve(TaskResolutionType.Approved) && ( + {canQuickResolve() && (