diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryResourceIT.java index 98f8289cd0de..f345fe19e0f9 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryResourceIT.java @@ -39,6 +39,7 @@ import org.openmetadata.schema.type.ApiStatus; import org.openmetadata.schema.type.EntityHistory; import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.EntityStatus; import org.openmetadata.schema.type.FieldChange; import org.openmetadata.schema.type.TagLabel; import org.openmetadata.schema.type.csv.CsvImportResult; @@ -1425,6 +1426,150 @@ void test_importCsv_withApprovedRelatedTerm_succeeds(TestNamespace ns) } } + /** + * Regression test for issue #29510: glossaryStatus column must be parsed case-insensitively. + * Users supplying valid status values in any casing (draft, APPROVED, drAFT, deprecated, etc.) + * should produce a successful import with the canonical {@link EntityStatus} stored on the term. + * Invalid values must still surface as a clear validation error. + */ + @Test + void test_importCsv_glossaryStatusIsCaseInsensitive(TestNamespace ns) { + OpenMetadataClient client = SdkClients.adminClient(); + + Glossary glossary = createEntity(createMinimalRequest(ns)); + String header = + client.glossaries().exportCsv(glossary.getName()).lines().findFirst().orElse(""); + assertTrue(header.contains("glossaryStatus"), "Exported header must include glossaryStatus"); + + List cases = + List.of( + new StatusCase(ns.prefix("statusDraft"), "Lower Draft", "draft (lower)", "draft"), + new StatusCase( + ns.prefix("statusApproved"), "Upper Approved", "approved (upper)", "APPROVED"), + new StatusCase( + ns.prefix("statusDeprecated"), + "Lower Deprecated", + "deprecated (lower)", + "deprecated"), + new StatusCase(ns.prefix("statusMixed"), "Mixed Draft", "drAFT (mixed)", "drAFT")); + + StringBuilder csvBuilder = new StringBuilder(header).append('\n'); + for (StatusCase c : cases) { + csvBuilder.append( + buildStatusRow(header, c.termName(), c.displayName(), c.description(), c.csvCasing())); + } + + String resultJson = + client.glossaries().importCsv(glossary.getName(), csvBuilder.toString(), false); + CsvImportResult importResult = JsonUtils.readValue(resultJson, CsvImportResult.class); + assertEquals( + ApiStatus.SUCCESS, + importResult.getStatus(), + "Case-insensitive glossaryStatus import should succeed. Result: " + resultJson); + assertEquals(cases.size(), importResult.getNumberOfRowsPassed()); + assertEquals(0, importResult.getNumberOfRowsFailed()); + + String resultsCsv = importResult.getImportResultsCsv(); + assertNotNull(resultsCsv, "Per-row import results should be present. Result: " + resultJson); + for (StatusCase c : cases) { + boolean rowSucceeded = + resultsCsv + .lines() + .anyMatch( + line -> + line.startsWith("success") + && line.contains(c.termName()) + && line.contains("," + c.csvCasing() + ",")); + assertTrue( + rowSucceeded, + "Row for '" + + c.termName() + + "' with casing '" + + c.csvCasing() + + "' should be reported as success. importResultsCsv=" + + resultsCsv); + } + + for (StatusCase c : cases) { + GlossaryTerm term = + client.glossaryTerms().getByName(glossary.getFullyQualifiedName() + "." + c.termName()); + assertNotNull( + term.getEntityStatus(), + "Imported term '" + c.termName() + "' must have an entityStatus assigned"); + } + } + + private record StatusCase( + String termName, String displayName, String description, String csvCasing) {} + + /** Invalid glossaryStatus values must still surface as a clear validation failure. */ + @Test + void test_importCsv_glossaryStatusInvalidValueFails(TestNamespace ns) { + OpenMetadataClient client = SdkClients.adminClient(); + + Glossary glossary = createEntity(createMinimalRequest(ns)); + String header = + client.glossaries().exportCsv(glossary.getName()).lines().findFirst().orElse(""); + + String badTermName = ns.prefix("statusBad"); + String csv = + header + + "\n" + + buildStatusRow(header, badTermName, "Bad Status", "invalid status", "not-a-status"); + + String resultJson = client.glossaries().importCsv(glossary.getName(), csv, false); + assertNotNull(resultJson); + CsvImportResult importResult = JsonUtils.readValue(resultJson, CsvImportResult.class); + assertTrue( + importResult.getNumberOfRowsFailed() != null && importResult.getNumberOfRowsFailed() >= 1, + "Import should report at least one failed row. Result: " + resultJson); + assertTrue( + importResult.getImportResultsCsv() != null + && importResult.getImportResultsCsv().contains("not-a-status"), + "Failure message should reference the invalid status value. Result: " + resultJson); + assertTrue( + importResult.getImportResultsCsv().contains("is invalid"), + "Failure message should mark the value as invalid. Result: " + resultJson); + } + + private static String buildStatusRow( + String header, String name, String displayName, String description, String status) { + String[] columns = header.split(","); + int statusIdx = -1; + int nameIdx = -1; + int displayIdx = -1; + int descIdx = -1; + for (int i = 0; i < columns.length; i++) { + String col = columns[i].trim(); + if (col.equals("glossaryStatus")) { + statusIdx = i; + } else if (col.equals("name*") || col.equals("name")) { + nameIdx = i; + } else if (col.equals("displayName")) { + displayIdx = i; + } else if (col.equals("description")) { + descIdx = i; + } + } + String[] row = new String[columns.length]; + for (int i = 0; i < row.length; i++) { + row[i] = ""; + } + if (nameIdx >= 0) { + row[nameIdx] = "\"" + name + "\""; + } + if (displayIdx >= 0) { + row[displayIdx] = "\"" + displayName + "\""; + } + if (descIdx >= 0) { + row[descIdx] = "\"" + description + "\""; + } + if (statusIdx >= 0) { + row[statusIdx] = status; + } + return String.join(",", row) + "\n"; + } + /** * Regression test: glossary terms must keep their assigned domains through a CSV export/import * round-trip (the UI "bulk edit" flow). Domains were silently dropped because the glossary CSV diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryRepository.java index a715a602e705..c424ad6f56b6 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryRepository.java @@ -85,6 +85,7 @@ import org.openmetadata.service.resources.glossary.GlossaryResource; import org.openmetadata.service.resources.settings.SettingsCache; import org.openmetadata.service.security.policyevaluator.PolicyConditionUpdater; +import org.openmetadata.service.util.EntityFieldUtils; import org.openmetadata.service.util.EntityUtil.Fields; import org.openmetadata.service.util.EntityUtil.RelationIncludes; import org.openmetadata.service.util.FullyQualifiedName; @@ -485,7 +486,9 @@ private EntityStatus getTermStatus(CSVPrinter printer, CSVRecord csvRecord) thro } String termStatus = csvRecord.get(10); try { - return nullOrEmpty(termStatus) ? EntityStatus.DRAFT : EntityStatus.fromValue(termStatus); + return nullOrEmpty(termStatus) + ? EntityStatus.DRAFT + : EntityFieldUtils.parseEntityStatus(termStatus); } catch (Exception ex) { // List should have even numbered terms - termName and endPoint importFailure( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MetricRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MetricRepository.java index b7db289f8946..6a307e0442cf 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MetricRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MetricRepository.java @@ -67,6 +67,7 @@ import org.openmetadata.service.jdbi3.FeedRepository.ThreadContext; import org.openmetadata.service.resources.metrics.MetricResource; import org.openmetadata.service.security.AuthorizationException; +import org.openmetadata.service.util.EntityFieldUtils; import org.openmetadata.service.util.EntityUtil; import org.openmetadata.service.util.EntityUtil.RelationIncludes; import org.openmetadata.service.util.MemoryOwnership; @@ -421,7 +422,7 @@ private EntityStatus getEntityStatus(CSVPrinter printer, CSVRecord csvRecord, in return null; } try { - return EntityStatus.fromValue(csvRecord.get(fieldNumber)); + return EntityFieldUtils.parseEntityStatus(csvRecord.get(fieldNumber)); } catch (Exception ex) { importFailure( printer, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityFieldUtils.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityFieldUtils.java index a0aaf5c76863..22f1622d5d48 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityFieldUtils.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/EntityFieldUtils.java @@ -488,6 +488,24 @@ public static void setReviewers(EntityInterface entity, String reviewerNames) { } } + /** + * Parses an {@link EntityStatus} from a string in a case-insensitive way so that + * CSV imports and other user inputs accept values like {@code draft} / {@code DRAFT} + * in addition to the canonical {@code Draft}. Multi-word values such as + * {@code "In Review"} are matched ignoring case as well. + * + * @throws IllegalArgumentException when the value does not match any enum constant + */ + public static EntityStatus parseEntityStatus(String value) { + String normalized = value == null ? "" : value.trim(); + for (EntityStatus status : EntityStatus.values()) { + if (status.value().equalsIgnoreCase(normalized)) { + return status; + } + } + throw new IllegalArgumentException("Unknown EntityStatus value: " + value); + } + /** * Sets the status field on various entity types using appropriate enum values. * Handles both legacy 'status' field and new 'entityStatus' field. @@ -495,7 +513,7 @@ public static void setReviewers(EntityInterface entity, String reviewerNames) { public static void setEntityStatus(EntityInterface entity, String statusValue) { try { try { - EntityStatus status = EntityStatus.fromValue(statusValue); + EntityStatus status = parseEntityStatus(statusValue); entity.setEntityStatus(status); LOG.debug( "Successfully set entityStatus to '{}' on entity type: {}", diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/util/EntityFieldUtilsTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/util/EntityFieldUtilsTest.java index f627d6b6d464..7ed4971ff344 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/util/EntityFieldUtilsTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/util/EntityFieldUtilsTest.java @@ -5,6 +5,7 @@ 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.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -413,6 +414,40 @@ void setReviewersResolvesUsersAndTeamsAndClearsWhenEmpty() { } } + @Test + void parseEntityStatusAcceptsCanonicalAndMixedCaseValues() { + assertEquals(EntityStatus.DRAFT, EntityFieldUtils.parseEntityStatus("Draft")); + assertEquals(EntityStatus.DRAFT, EntityFieldUtils.parseEntityStatus("draft")); + assertEquals(EntityStatus.DRAFT, EntityFieldUtils.parseEntityStatus("DRAFT")); + assertEquals(EntityStatus.DRAFT, EntityFieldUtils.parseEntityStatus("drAFT")); + assertEquals(EntityStatus.DRAFT, EntityFieldUtils.parseEntityStatus("DrAfT")); + assertEquals(EntityStatus.APPROVED, EntityFieldUtils.parseEntityStatus("approved")); + assertEquals(EntityStatus.APPROVED, EntityFieldUtils.parseEntityStatus("APPROVED")); + assertEquals(EntityStatus.APPROVED, EntityFieldUtils.parseEntityStatus("ApPrOvEd")); + assertEquals(EntityStatus.DEPRECATED, EntityFieldUtils.parseEntityStatus("Deprecated")); + assertEquals(EntityStatus.DEPRECATED, EntityFieldUtils.parseEntityStatus("deprecated")); + assertEquals(EntityStatus.DEPRECATED, EntityFieldUtils.parseEntityStatus("DEPRECATED")); + assertEquals(EntityStatus.IN_REVIEW, EntityFieldUtils.parseEntityStatus("in review")); + assertEquals(EntityStatus.IN_REVIEW, EntityFieldUtils.parseEntityStatus("IN REVIEW")); + assertEquals(EntityStatus.IN_REVIEW, EntityFieldUtils.parseEntityStatus("In Review")); + assertEquals(EntityStatus.REJECTED, EntityFieldUtils.parseEntityStatus("rejected")); + assertEquals(EntityStatus.ARCHIVED, EntityFieldUtils.parseEntityStatus("ARCHIVED")); + } + + @Test + void parseEntityStatusTrimsSurroundingWhitespace() { + assertEquals(EntityStatus.DRAFT, EntityFieldUtils.parseEntityStatus(" draft ")); + assertEquals(EntityStatus.APPROVED, EntityFieldUtils.parseEntityStatus("Approved\t")); + assertEquals(EntityStatus.IN_REVIEW, EntityFieldUtils.parseEntityStatus("\n In Review \n")); + } + + @Test + void parseEntityStatusThrowsForUnknownValues() { + assertThrows( + IllegalArgumentException.class, () -> EntityFieldUtils.parseEntityStatus("not-a-status")); + assertThrows(IllegalArgumentException.class, () -> EntityFieldUtils.parseEntityStatus(" ")); + } + @Test void setEntityStatusSupportsEnumAndLegacyFallbacks() { TestEntity entity = entity();