Fixe Issue #11863 - Add Status logic for test case results (#11881)

* feat: added entityReference field in testSuite to link testSuite to an entity when the testSuite is executable.

* feat: added `executableEntityReference` as an entity reference for executable test suite to their entity

* feat: add status object to test case results

* feat: ran python linting
This commit is contained in:
Teddy 2023-06-06 09:45:49 +02:00 committed by GitHub
parent 236141d9df
commit 06735fe8db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 377 additions and 46 deletions

View File

@ -1,7 +1,8 @@
{ {
"tests": [ "tests": [
{ {
"testSuiteName": "sample_data.ecommerce_db.shopify.dim_address", "testSuiteName": "sample_data.ecommerce_db.shopify.dim_address.TestSuite",
"executableEntityReference": "sample_data.ecommerce_db.shopify.dim_address",
"testSuiteDescription": "This is an executable test suite linked to an entity", "testSuiteDescription": "This is an executable test suite linked to an entity",
"scheduleInterval": "0 0 * * MON", "scheduleInterval": "0 0 * * MON",
"testCases": [ "testCases": [

View File

@ -50,6 +50,7 @@ from metadata.generated.schema.metadataIngestion.workflow import (
OpenMetadataWorkflowConfig, OpenMetadataWorkflowConfig,
) )
from metadata.generated.schema.tests.testCase import TestCase from metadata.generated.schema.tests.testCase import TestCase
from metadata.generated.schema.tests.testDefinition import TestDefinition, TestPlatform
from metadata.generated.schema.tests.testSuite import TestSuite from metadata.generated.schema.tests.testSuite import TestSuite
from metadata.generated.schema.type.basic import EntityLink, FullyQualifiedEntityName from metadata.generated.schema.type.basic import EntityLink, FullyQualifiedEntityName
from metadata.ingestion.api.parser import parse_workflow_config_gracefully from metadata.ingestion.api.parser import parse_workflow_config_gracefully
@ -115,6 +116,10 @@ class TestSuiteWorkflow(WorkflowStatusMixin):
self.status = ProcessorStatus() self.status = ProcessorStatus()
self.table_entity: Optional[Table] = self._get_table_entity(
self.source_config.entityFullyQualifiedName.__root__
)
if self.config.sink: if self.config.sink:
self.sink = get_sink( self.sink = get_sink(
sink_type=self.config.sink.type, sink_type=self.config.sink.type,
@ -142,7 +147,7 @@ class TestSuiteWorkflow(WorkflowStatusMixin):
) )
raise err raise err
def _get_table_entity(self, entity_fqn: str): def _get_table_entity(self, entity_fqn: str) -> Optional[Table]:
"""given an entity fqn return the table entity """given an entity fqn return the table entity
Args: Args:
@ -151,20 +156,17 @@ class TestSuiteWorkflow(WorkflowStatusMixin):
return self.metadata.get_by_name( return self.metadata.get_by_name(
entity=Table, entity=Table,
fqn=entity_fqn, fqn=entity_fqn,
fields=["tableProfilerConfig"], fields=["tableProfilerConfig", "testSuite"],
) )
def get_test_suite_entity(self) -> Optional[TestSuite]: def create_or_return_test_suite_entity(self) -> Optional[TestSuite]:
""" """
try to get test suite name from source.servicName. try to get test suite name from source.servicName.
In the UI workflow we'll write the entity name (i.e. the test suite) In the UI workflow we'll write the entity name (i.e. the test suite)
to source.serviceName. to source.serviceName.
""" """
test_suite = self.metadata.get_by_name( self.table_entity = cast(Table, self.table_entity) # satisfy type checker
entity=TestSuite, test_suite = self.table_entity.testSuite
fqn=self.source_config.entityFullyQualifiedName.__root__,
)
if test_suite and not test_suite.executable: if test_suite and not test_suite.executable:
logger.debug( logger.debug(
f"Test suite {test_suite.fullyQualifiedName.__root__} is not executable." f"Test suite {test_suite.fullyQualifiedName.__root__} is not executable."
@ -180,10 +182,11 @@ class TestSuiteWorkflow(WorkflowStatusMixin):
) )
test_suite = self.metadata.create_or_update_executable_test_suite( test_suite = self.metadata.create_or_update_executable_test_suite(
CreateTestSuiteRequest( CreateTestSuiteRequest(
name=self.source_config.entityFullyQualifiedName.__root__, name=f"{self.source_config.entityFullyQualifiedName.__root__}.TestSuite",
displayName=self.source_config.entityFullyQualifiedName.__root__, displayName=f"{self.source_config.entityFullyQualifiedName.__root__} Test Suite",
description="Test Suite created from YAML processor config file", description="Test Suite created from YAML processor config file",
owner=None, owner=None,
executableEntityReference=self.source_config.entityFullyQualifiedName.__root__,
) )
) )
@ -209,10 +212,33 @@ class TestSuiteWorkflow(WorkflowStatusMixin):
cli_test_cases = cast( cli_test_cases = cast(
List[TestCaseDefinition], cli_test_cases List[TestCaseDefinition], cli_test_cases
) # satisfy type checker ) # satisfy type checker
test_cases = self.compare_and_create_test_cases(cli_test_cases, test_cases) test_cases = self.compare_and_create_test_cases(
cli_test_cases, test_cases, test_suite
)
return test_cases return test_cases
def filter_for_om_test_cases(self, test_cases: List[TestCase]) -> List[TestCase]:
"""
Filter test cases for OM test cases only. This will prevent us from running non OM test cases
Args:
test_cases: list of test cases
"""
om_test_cases: List[TestCase] = []
for test_case in test_cases:
test_definition: TestDefinition = self.metadata.get_by_id(
TestDefinition, test_case.testDefinition.id
)
if TestPlatform.OpenMetadata not in test_definition.testPlatforms:
logger.debug(
f"Test case {test_case.name.__root__} is not an OpenMetadata test case."
)
continue
om_test_cases.append(test_case)
return om_test_cases
def get_test_case_from_cli_config( def get_test_case_from_cli_config(
self, self,
) -> Optional[List[TestCaseDefinition]]: ) -> Optional[List[TestCaseDefinition]]:
@ -257,6 +283,7 @@ class TestSuiteWorkflow(WorkflowStatusMixin):
self, self,
cli_test_cases_definitions: Optional[List[TestCaseDefinition]], cli_test_cases_definitions: Optional[List[TestCaseDefinition]],
test_cases: List[TestCase], test_cases: List[TestCase],
test_suite: TestSuite,
) -> Optional[List[TestCase]]: ) -> Optional[List[TestCase]]:
""" """
compare test cases defined in CLI config workflow with test cases compare test cases defined in CLI config workflow with test cases
@ -306,7 +333,7 @@ class TestSuiteWorkflow(WorkflowStatusMixin):
test_case_to_create.columnName, test_case_to_create.columnName,
) )
), ),
testSuite=self.source_config.entityFullyQualifiedName, testSuite=test_suite.fullyQualifiedName.__root__,
parameterValues=list(test_case_to_create.parameterValues) parameterValues=list(test_case_to_create.parameterValues)
if test_case_to_create.parameterValues if test_case_to_create.parameterValues
else None, else None,
@ -330,17 +357,14 @@ class TestSuiteWorkflow(WorkflowStatusMixin):
def run_test_suite(self): def run_test_suite(self):
"""Main logic to run the tests""" """Main logic to run the tests"""
table_entity: Table = self._get_table_entity( if not self.table_entity:
self.source_config.entityFullyQualifiedName.__root__
)
if not table_entity:
logger.debug(traceback.format_exc()) logger.debug(traceback.format_exc())
raise ValueError( raise ValueError(
f"Could not retrieve table entity for {self.source_config.entityFullyQualifiedName.__root__}" f"Could not retrieve table entity for {self.source_config.entityFullyQualifiedName.__root__}. "
"Make sure the table exists in OpenMetadata and/or the JWT Token provided is valid." "Make sure the table exists in OpenMetadata and/or the JWT Token provided is valid."
) )
test_suite = self.get_test_suite_entity() test_suite = self.create_or_return_test_suite_entity()
if not test_suite: if not test_suite:
logger.debug( logger.debug(
f"No test suite found for table {self.source_config.entityFullyQualifiedName.__root__} " f"No test suite found for table {self.source_config.entityFullyQualifiedName.__root__} "
@ -356,14 +380,16 @@ class TestSuiteWorkflow(WorkflowStatusMixin):
) )
return return
openmetadata_test_cases = self.filter_for_om_test_cases(test_cases)
test_suite_runner = test_suite_source_factory.create( test_suite_runner = test_suite_source_factory.create(
self.config.source.type.lower(), self.config.source.type.lower(),
self.config, self.config,
self.metadata, self.metadata,
table_entity, self.table_entity,
).get_data_quality_runner() ).get_data_quality_runner()
for test_case in test_cases: for test_case in openmetadata_test_cases:
try: try:
test_result = test_suite_runner.run_and_handle(test_case) test_result = test_suite_runner.run_and_handle(test_case)
if not test_result: if not test_result:

View File

@ -21,11 +21,14 @@ from datetime import datetime
from typing import Callable, List, Optional, TypeVar, Union from typing import Callable, List, Optional, TypeVar, Union
from metadata.generated.schema.tests.basic import ( from metadata.generated.schema.tests.basic import (
TestCaseFailureStatus,
TestCaseFailureStatusType,
TestCaseResult, TestCaseResult,
TestCaseStatus, TestCaseStatus,
TestResultValue, TestResultValue,
) )
from metadata.generated.schema.tests.testCase import TestCase, TestCaseParameterValue from metadata.generated.schema.tests.testCase import TestCase, TestCaseParameterValue
from metadata.generated.schema.type.basic import Timestamp
from metadata.profiler.processor.runner import QueryRunner from metadata.profiler.processor.runner import QueryRunner
T = TypeVar("T", bound=Callable) T = TypeVar("T", bound=Callable)
@ -102,12 +105,24 @@ class BaseTestValidator(ABC):
Returns: Returns:
TestCaseResult: TestCaseResult:
""" """
if status == TestCaseStatus.Failed:
test_case_failure_status = TestCaseFailureStatus(
testCaseFailureStatusType=TestCaseFailureStatusType.New,
testCaseFailureReason=None,
testCaseFailureComment=None,
updatedAt=Timestamp(__root__=int(datetime.utcnow().timestamp() * 1000)),
updatedBy=None,
)
else:
test_case_failure_status = None
return TestCaseResult( return TestCaseResult(
timestamp=execution_date, # type: ignore timestamp=execution_date, # type: ignore
testCaseStatus=status, testCaseStatus=status,
result=result, result=result,
testResultValue=test_result_value, testResultValue=test_result_value,
sampleData=None, sampleData=None,
testCaseFailureStatus=test_case_failure_status,
) )
def format_column_list(self, status: TestCaseStatus, cols: List): def format_column_list(self, status: TestCaseStatus, cols: List):

View File

@ -1098,6 +1098,7 @@ class SampleDataSource(
test_suite=CreateTestSuiteRequest( test_suite=CreateTestSuiteRequest(
name=test_suite["testSuiteName"], name=test_suite["testSuiteName"],
description=test_suite["testSuiteDescription"], description=test_suite["testSuiteDescription"],
executableEntityReference=test_suite["executableEntityReference"],
) )
) )

View File

@ -82,8 +82,9 @@ class OMetaTestSuiteTest(TestCase):
cls.test_suite: TestSuite = cls.metadata.create_or_update_executable_test_suite( cls.test_suite: TestSuite = cls.metadata.create_or_update_executable_test_suite(
CreateTestSuiteRequest( CreateTestSuiteRequest(
name="sample_data.ecommerce_db.shopify.dim_address", name="sample_data.ecommerce_db.shopify.dim_address.TestSuite",
description="This is a test suite for the integration tests", description="This is a test suite for the integration tests",
executableEntityReference="sample_data.ecommerce_db.shopify.dim_address",
) )
) )
@ -111,10 +112,11 @@ class OMetaTestSuiteTest(TestCase):
def test_get_or_create_test_suite(self): def test_get_or_create_test_suite(self):
"""test we get a test suite object""" """test we get a test suite object"""
test_suite = self.metadata.get_or_create_test_suite( test_suite = self.metadata.get_or_create_test_suite(
"sample_data.ecommerce_db.shopify.dim_address" "sample_data.ecommerce_db.shopify.dim_address.TestSuite"
) )
assert ( assert (
test_suite.name.__root__ == "sample_data.ecommerce_db.shopify.dim_address" test_suite.name.__root__
== "sample_data.ecommerce_db.shopify.dim_address.TestSuite"
) )
assert isinstance(test_suite, TestSuite) assert isinstance(test_suite, TestSuite)

View File

@ -23,6 +23,7 @@ from metadata.generated.schema.entity.services.connections.metadata.openMetadata
OpenMetadataConnection, OpenMetadataConnection,
) )
from metadata.generated.schema.tests.testCase import TestCase from metadata.generated.schema.tests.testCase import TestCase
from metadata.generated.schema.tests.testDefinition import TestDefinition
from metadata.generated.schema.tests.testSuite import TestSuite from metadata.generated.schema.tests.testSuite import TestSuite
from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.ingestion.ometa.ometa_api import OpenMetadata
@ -115,21 +116,22 @@ class TestSuiteWorkflowTests(unittest.TestCase):
_test_suite_config.update(processor) _test_suite_config.update(processor)
workflow = TestSuiteWorkflow.create(_test_suite_config) workflow = TestSuiteWorkflow.create(_test_suite_config)
workflow_test_suite = workflow.get_test_suite_entity() workflow_test_suite = workflow.create_or_return_test_suite_entity()
test_suite = self.metadata.get_by_name( test_suite = self.metadata.get_by_name(
entity=TestSuite, fqn="sample_data.ecommerce_db.shopify.dim_address" entity=TestSuite,
fqn="sample_data.ecommerce_db.shopify.dim_address.TestSuite",
) )
assert workflow_test_suite.id == test_suite.id assert workflow_test_suite.id == test_suite.id
self.test_suite_ids = [test_suite.id] self.test_suite_ids = [test_suite.id]
def test_get_test_suite_entity(self): def test_create_or_return_test_suite_entity(self):
"""test we can correctly retrieve a test suite""" """test we can correctly retrieve a test suite"""
_test_suite_config = deepcopy(test_suite_config) _test_suite_config = deepcopy(test_suite_config)
workflow = TestSuiteWorkflow.create(_test_suite_config) workflow = TestSuiteWorkflow.create(_test_suite_config)
test_suite = workflow.get_test_suite_entity() test_suite = workflow.create_or_return_test_suite_entity()
expected_test_suite = self.metadata.get_by_name( expected_test_suite = self.metadata.get_by_name(
entity=TestSuite, fqn="critical_metrics_suite" entity=TestSuite, fqn="critical_metrics_suite"
@ -162,7 +164,7 @@ class TestSuiteWorkflowTests(unittest.TestCase):
_test_suite_config.update(processor) _test_suite_config.update(processor)
workflow = TestSuiteWorkflow.create(_test_suite_config) workflow = TestSuiteWorkflow.create(_test_suite_config)
test_suite = workflow.get_test_suite_entity() test_suite = workflow.create_or_return_test_suite_entity()
test_cases = workflow.get_test_cases_from_test_suite(test_suite) test_cases = workflow.get_test_cases_from_test_suite(test_suite)
assert isinstance(test_cases, MutableSequence) assert isinstance(test_cases, MutableSequence)
@ -258,7 +260,7 @@ class TestSuiteWorkflowTests(unittest.TestCase):
fqn="sample_data.ecommerce_db.shopify.dim_address.address_id.my_test_case_two", fqn="sample_data.ecommerce_db.shopify.dim_address.address_id.my_test_case_two",
) )
test_suite = workflow.get_test_suite_entity() test_suite = workflow.create_or_return_test_suite_entity()
test_cases = self.metadata.list_entities( test_cases = self.metadata.list_entities(
entity=TestCase, entity=TestCase,
fields=["testSuite", "entityLink", "testDefinition"], fields=["testSuite", "entityLink", "testDefinition"],
@ -266,7 +268,7 @@ class TestSuiteWorkflowTests(unittest.TestCase):
).entities ).entities
config_test_cases_def = workflow.get_test_case_from_cli_config() config_test_cases_def = workflow.get_test_case_from_cli_config()
created_test_case = workflow.compare_and_create_test_cases( created_test_case = workflow.compare_and_create_test_cases(
config_test_cases_def, test_cases config_test_cases_def, test_cases, test_suite
) )
# clean up test # clean up test
@ -297,3 +299,15 @@ class TestSuiteWorkflowTests(unittest.TestCase):
) )
assert isinstance(service_connection, Table) assert isinstance(service_connection, Table)
# def test_filter_for_om_test_cases(self):
# """test filter for OM test cases method"""
# om_test_case_1 = TestCase(
# name="om_test_case_1",
# testDefinition=self.metadata.get_entity_reference(
# TestDefinition,
# "columnValuesToMatchRegex"
# ),
# entityLink="<entityLink>",
# testSuite=self.metadata.get_entity_reference("sample_data.ecommerce_db.shopify.dim_address.TestSuite"),
# )

View File

@ -20,7 +20,11 @@ from unittest.mock import patch
import pytest import pytest
from metadata.data_quality.validations.validator import Validator from metadata.data_quality.validations.validator import Validator
from metadata.generated.schema.tests.basic import TestCaseResult, TestCaseStatus from metadata.generated.schema.tests.basic import (
TestCaseFailureStatusType,
TestCaseResult,
TestCaseStatus,
)
from metadata.utils.importer import import_test_case_class from metadata.utils.importer import import_test_case_class
EXECUTION_DATE = datetime.strptime("2021-07-03", "%Y-%m-%d") EXECUTION_DATE = datetime.strptime("2021-07-03", "%Y-%m-%d")
@ -335,3 +339,9 @@ def test_suite_validation_database(
if val_2: if val_2:
assert res.testResultValue[1].value == val_2 assert res.testResultValue[1].value == val_2
assert res.testCaseStatus == status assert res.testCaseStatus == status
if res.testCaseStatus == TestCaseStatus.Failed:
assert (
res.testCaseFailureStatus.testCaseFailureStatusType
== TestCaseFailureStatusType.New
)
assert res.testCaseFailureStatus.updatedAt is not None

View File

@ -21,7 +21,11 @@ import pytest
from pandas import DataFrame from pandas import DataFrame
from metadata.data_quality.validations.validator import Validator from metadata.data_quality.validations.validator import Validator
from metadata.generated.schema.tests.basic import TestCaseResult, TestCaseStatus from metadata.generated.schema.tests.basic import (
TestCaseFailureStatusType,
TestCaseResult,
TestCaseStatus,
)
from metadata.utils.importer import import_test_case_class from metadata.utils.importer import import_test_case_class
EXECUTION_DATE = datetime.strptime("2021-07-03", "%Y-%m-%d") EXECUTION_DATE = datetime.strptime("2021-07-03", "%Y-%m-%d")
@ -148,7 +152,7 @@ DATALAKE_DATA_FRAME = lambda times_increase_sample_data: DataFrame(
"test_case_column_values_missing_count_to_be_equal", "test_case_column_values_missing_count_to_be_equal",
"columnValuesMissingCount", "columnValuesMissingCount",
"COLUMN", "COLUMN",
(TestCaseResult, "2000", None, TestCaseStatus.Success), (TestCaseResult, "2000", None, TestCaseStatus.Failed),
), ),
( (
"test_case_column_values_missing_count_to_be_equal_missing_values", "test_case_column_values_missing_count_to_be_equal_missing_values",
@ -249,7 +253,7 @@ DATALAKE_DATA_FRAME = lambda times_increase_sample_data: DataFrame(
"test_case_table_row_count_to_be_between", "test_case_table_row_count_to_be_between",
"tableRowCountToBeBetween", "tableRowCountToBeBetween",
"TABLE", "TABLE",
(TestCaseResult, "6000", None, TestCaseStatus.Success), (TestCaseResult, "6000", None, TestCaseStatus.Failed),
), ),
( (
"test_case_table_row_count_to_be_equal", "test_case_table_row_count_to_be_equal",
@ -298,4 +302,12 @@ def test_suite_validation_datalake(
assert res.testResultValue[0].value == val_1 assert res.testResultValue[0].value == val_1
if val_2: if val_2:
assert res.testResultValue[1].value == val_2 assert res.testResultValue[1].value == val_2
assert res.testCaseStatus == status assert res.testCaseStatus == status
if res.testCaseStatus == TestCaseStatus.Failed:
assert (
res.testCaseFailureStatus.testCaseFailureStatusType
== TestCaseFailureStatusType.New
)
assert res.testCaseFailureStatus.updatedAt is not None

View File

@ -3,12 +3,16 @@ package org.openmetadata.service.jdbi3;
import static org.openmetadata.service.Entity.TEST_CASE; import static org.openmetadata.service.Entity.TEST_CASE;
import static org.openmetadata.service.Entity.TEST_DEFINITION; import static org.openmetadata.service.Entity.TEST_DEFINITION;
import static org.openmetadata.service.Entity.TEST_SUITE; import static org.openmetadata.service.Entity.TEST_SUITE;
import static org.openmetadata.service.util.RestUtil.ENTITY_NO_CHANGE;
import static org.openmetadata.service.util.RestUtil.ENTITY_UPDATED;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import javax.json.JsonPatch;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo; import javax.ws.rs.core.UriInfo;
import org.jdbi.v3.sqlobject.transaction.Transaction; import org.jdbi.v3.sqlobject.transaction.Transaction;
@ -55,6 +59,29 @@ public class TestCaseRepository extends EntityRepository<TestCase> {
return test.withTestCaseResult(fields.contains(TEST_CASE_RESULT_FIELD) ? getTestCaseResult(test) : null); return test.withTestCaseResult(fields.contains(TEST_CASE_RESULT_FIELD) ? getTestCaseResult(test) : null);
} }
public RestUtil.PatchResponse<TestCaseResult> patchTestCaseResults(
String fqn, Long timestamp, UriInfo uriInfo, String user, JsonPatch patch) throws IOException {
String change = ENTITY_NO_CHANGE;
TestCaseResult original =
JsonUtils.readValue(
daoCollection
.entityExtensionTimeSeriesDao()
.getExtensionAtTimestamp(fqn, TESTCASE_RESULT_EXTENSION, timestamp),
TestCaseResult.class);
TestCaseResult updated = JsonUtils.applyPatch(original, patch, TestCaseResult.class);
if (!Objects.equals(original.getTestCaseFailureStatus(), updated.getTestCaseFailureStatus())) {
updated.getTestCaseFailureStatus().setUpdatedBy(user);
updated.getTestCaseFailureStatus().setUpdatedAt(System.currentTimeMillis());
daoCollection
.entityExtensionTimeSeriesDao()
.update(fqn, TESTCASE_RESULT_EXTENSION, JsonUtils.pojoToJson(updated), timestamp);
change = ENTITY_UPDATED;
}
return new RestUtil.PatchResponse<>(Response.Status.OK, updated, change);
}
@Override @Override
public void setFullyQualifiedName(TestCase test) { public void setFullyQualifiedName(TestCase test) {
EntityLink entityLink = EntityLink.parse(test.getEntityLink()); EntityLink entityLink = EntityLink.parse(test.getEntityLink());

View File

@ -67,7 +67,9 @@ public class TestSuiteRepository extends EntityRepository<TestSuite> {
} }
public void storeExecutableRelationship(TestSuite testSuite) throws IOException { public void storeExecutableRelationship(TestSuite testSuite) throws IOException {
Table table = Entity.getEntityByName(Entity.TABLE, testSuite.getName(), null, null); Table table =
Entity.getEntityByName(
Entity.TABLE, testSuite.getExecutableEntityReference().getFullyQualifiedName(), null, null);
addRelationship(table.getId(), testSuite.getId(), Entity.TABLE, TEST_SUITE, Relationship.CONTAINS); addRelationship(table.getId(), testSuite.getId(), Entity.TABLE, TEST_SUITE, Relationship.CONTAINS);
} }

View File

@ -361,6 +361,40 @@ public class TestCaseResource extends EntityResource<TestCase, TestCaseRepositor
return response.toResponse(); return response.toResponse();
} }
@PATCH
@Path("/{fqn}/testCaseResult/{timestamp}")
@Operation(
operationId = "patchTestCaseResult",
summary = "Update a test case result",
description = "Update an existing test case using JsonPatch.",
externalDocs = @ExternalDocumentation(description = "JsonPatch RFC", url = "https://tools.ietf.org/html/rfc6902"))
@Consumes(MediaType.APPLICATION_JSON_PATCH_JSON)
public Response patchTestCaseResult(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(description = "fqn of the test case", schema = @Schema(type = "string")) @PathParam("fqn") String fqn,
@Parameter(description = "Timestamp of the testCase result", schema = @Schema(type = "long"))
@PathParam("timestamp")
Long timestamp,
@RequestBody(
description = "JsonPatch with array of operations",
content =
@Content(
mediaType = MediaType.APPLICATION_JSON_PATCH_JSON,
examples = {
@ExampleObject("[" + "{op:remove, path:/a}," + "{op:add, path: /b, value: val}" + "]")
}))
JsonPatch patch)
throws IOException {
// Override OperationContext to change the entity to table and operation from UPDATE to EDIT_TESTS
ResourceContextInterface resourceContext = TestCaseResourceContext.builder().name(fqn).build();
OperationContext operationContext = new OperationContext(Entity.TABLE, MetadataOperation.EDIT_TESTS);
authorizer.authorize(securityContext, operationContext, resourceContext);
PatchResponse<TestCaseResult> patchResponse =
dao.patchTestCaseResults(fqn, timestamp, uriInfo, securityContext.getUserPrincipal().getName(), patch);
return patchResponse.toResponse();
}
@PUT @PUT
@Operation( @Operation(
operationId = "createOrUpdateTest", operationId = "createOrUpdateTest",

View File

@ -34,8 +34,10 @@ import javax.ws.rs.core.UriInfo;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.data.RestoreEntity; import org.openmetadata.schema.api.data.RestoreEntity;
import org.openmetadata.schema.api.tests.CreateTestSuite; import org.openmetadata.schema.api.tests.CreateTestSuite;
import org.openmetadata.schema.entity.data.Table;
import org.openmetadata.schema.tests.TestSuite; import org.openmetadata.schema.tests.TestSuite;
import org.openmetadata.schema.type.EntityHistory; import org.openmetadata.schema.type.EntityHistory;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.Include;
import org.openmetadata.service.Entity; import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.CollectionDAO; import org.openmetadata.service.jdbi3.CollectionDAO;
@ -248,6 +250,7 @@ public class TestSuiteResource extends EntityResource<TestSuite, TestSuiteReposi
public Response create( public Response create(
@Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTestSuite create) @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTestSuite create)
throws IOException { throws IOException {
create = create.withExecutableEntityReference(null); // entity reference is not applicable for logical test suites
TestSuite testSuite = getTestSuite(create, securityContext.getUserPrincipal().getName()); TestSuite testSuite = getTestSuite(create, securityContext.getUserPrincipal().getName());
testSuite.setExecutable(false); testSuite.setExecutable(false);
return create(uriInfo, securityContext, testSuite); return create(uriInfo, securityContext, testSuite);
@ -269,8 +272,7 @@ public class TestSuiteResource extends EntityResource<TestSuite, TestSuiteReposi
public Response createExecutable( public Response createExecutable(
@Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTestSuite create) @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTestSuite create)
throws IOException { throws IOException {
// We'll check if we have a corresponding table entity Entity.getEntityByName(Entity.TABLE, create.getExecutableEntityReference(), null, null); // check if entity exists
Entity.getEntityByName(Entity.TABLE, create.getName(), null, null);
TestSuite testSuite = getTestSuite(create, securityContext.getUserPrincipal().getName()); TestSuite testSuite = getTestSuite(create, securityContext.getUserPrincipal().getName());
testSuite.setExecutable(true); testSuite.setExecutable(true);
return create(uriInfo, securityContext, testSuite); return create(uriInfo, securityContext, testSuite);
@ -315,6 +317,7 @@ public class TestSuiteResource extends EntityResource<TestSuite, TestSuiteReposi
public Response createOrUpdate( public Response createOrUpdate(
@Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTestSuite create) @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTestSuite create)
throws IOException { throws IOException {
create = create.withExecutableEntityReference(null); // entity reference is not applicable for logical test suites
TestSuite testSuite = getTestSuite(create, securityContext.getUserPrincipal().getName()); TestSuite testSuite = getTestSuite(create, securityContext.getUserPrincipal().getName());
testSuite.setExecutable(false); testSuite.setExecutable(false);
return createOrUpdate(uriInfo, securityContext, testSuite); return createOrUpdate(uriInfo, securityContext, testSuite);
@ -335,7 +338,7 @@ public class TestSuiteResource extends EntityResource<TestSuite, TestSuiteReposi
public Response createOrUpdateExecutable( public Response createOrUpdateExecutable(
@Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTestSuite create) @Context UriInfo uriInfo, @Context SecurityContext securityContext, @Valid CreateTestSuite create)
throws IOException { throws IOException {
Entity.getEntityByName(Entity.TABLE, create.getName(), null, null); Entity.getEntityByName(Entity.TABLE, create.getExecutableEntityReference(), null, null); // Check if table exists
TestSuite testSuite = getTestSuite(create, securityContext.getUserPrincipal().getName()); TestSuite testSuite = getTestSuite(create, securityContext.getUserPrincipal().getName());
testSuite.setExecutable(true); testSuite.setExecutable(true);
return createOrUpdate(uriInfo, securityContext, testSuite); return createOrUpdate(uriInfo, securityContext, testSuite);
@ -409,9 +412,21 @@ public class TestSuiteResource extends EntityResource<TestSuite, TestSuiteReposi
} }
private TestSuite getTestSuite(CreateTestSuite create, String user) throws IOException { private TestSuite getTestSuite(CreateTestSuite create, String user) throws IOException {
return copy(new TestSuite(), create, user) TestSuite testSuite =
copy(new TestSuite(), create, user)
.withDescription(create.getDescription()) .withDescription(create.getDescription())
.withDisplayName(create.getDisplayName()) .withDisplayName(create.getDisplayName())
.withName(create.getName()); .withName(create.getName());
if (create.getExecutableEntityReference() != null) {
Table table = Entity.getEntityByName(Entity.TABLE, create.getExecutableEntityReference(), null, null);
EntityReference entityReference =
new EntityReference()
.withId(table.getId())
.withFullyQualifiedName(table.getFullyQualifiedName())
.withName(table.getName())
.withType(Entity.TABLE);
testSuite.setExecutableEntityReference(entityReference);
}
return testSuite;
} }
} }

View File

@ -20,11 +20,13 @@ import static org.openmetadata.service.util.TestUtils.assertListNotNull;
import static org.openmetadata.service.util.TestUtils.assertListNull; import static org.openmetadata.service.util.TestUtils.assertListNull;
import static org.openmetadata.service.util.TestUtils.assertResponse; import static org.openmetadata.service.util.TestUtils.assertResponse;
import static org.openmetadata.service.util.TestUtils.assertResponseContains; import static org.openmetadata.service.util.TestUtils.assertResponseContains;
import static org.openmetadata.service.util.TestUtils.dateToTimestamp;
import java.io.IOException; import java.io.IOException;
import java.text.ParseException; import java.text.ParseException;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.json.JsonPatch;
import javax.ws.rs.client.WebTarget; import javax.ws.rs.client.WebTarget;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.http.client.HttpResponseException; import org.apache.http.client.HttpResponseException;
@ -39,6 +41,9 @@ import org.openmetadata.schema.api.tests.CreateTestSuite;
import org.openmetadata.schema.tests.TestCase; import org.openmetadata.schema.tests.TestCase;
import org.openmetadata.schema.tests.TestCaseParameterValue; import org.openmetadata.schema.tests.TestCaseParameterValue;
import org.openmetadata.schema.tests.TestSuite; import org.openmetadata.schema.tests.TestSuite;
import org.openmetadata.schema.tests.type.TestCaseFailureReason;
import org.openmetadata.schema.tests.type.TestCaseFailureStatus;
import org.openmetadata.schema.tests.type.TestCaseFailureStatusType;
import org.openmetadata.schema.tests.type.TestCaseResult; import org.openmetadata.schema.tests.type.TestCaseResult;
import org.openmetadata.schema.tests.type.TestCaseStatus; import org.openmetadata.schema.tests.type.TestCaseStatus;
import org.openmetadata.schema.type.ChangeDescription; import org.openmetadata.schema.type.ChangeDescription;
@ -438,6 +443,97 @@ public class TestCaseResourceTest extends EntityResourceTest<TestCase, CreateTes
deleteAndCheckEntity(testCase, ownerAuthHeaders); deleteAndCheckEntity(testCase, ownerAuthHeaders);
} }
@Test
void patch_testCaseResultsFailureStatus_change(TestInfo test) throws IOException, ParseException {
CreateTestCase create =
createRequest(test)
.withEntityLink(TABLE_LINK_2)
.withTestSuite(TEST_SUITE1.getFullyQualifiedName())
.withTestDefinition(TEST_DEFINITION3.getFullyQualifiedName())
.withParameterValues(List.of(new TestCaseParameterValue().withValue("100").withName("missingCountValue")));
TestCase testCase = createAndCheckEntity(create, ADMIN_AUTH_HEADERS);
TestCaseResult testCaseResult =
new TestCaseResult()
.withResult("tested")
.withTestCaseStatus(TestCaseStatus.Failed)
.withTimestamp(TestUtils.dateToTimestamp("2021-09-09"));
TestCaseFailureStatus testCaseFailureStatus =
new TestCaseFailureStatus().withTestCaseFailureStatusType(TestCaseFailureStatusType.New);
testCaseResult.setTestCaseFailureStatus(testCaseFailureStatus);
putTestCaseResult(testCase.getFullyQualifiedName(), testCaseResult, ADMIN_AUTH_HEADERS);
ResultList<TestCaseResult> testCaseResultResultList =
getTestCaseResults(
testCase.getFullyQualifiedName(),
TestUtils.dateToTimestamp("2021-09-09"),
TestUtils.dateToTimestamp("2021-09-09"),
ADMIN_AUTH_HEADERS);
assertEquals(
testCaseResultResultList.getData().get(0).getTestCaseFailureStatus().getTestCaseFailureStatusType(),
TestCaseFailureStatusType.New);
String original = JsonUtils.pojoToJson(testCaseResult);
testCaseResult
.getTestCaseFailureStatus()
.withTestCaseFailureStatusType(TestCaseFailureStatusType.Resolved)
.withTestCaseFailureReason(TestCaseFailureReason.FalsePositive)
.withTestCaseFailureComment("Test failure was a false positive");
JsonPatch patch = JsonUtils.getJsonPatch(original, JsonUtils.pojoToJson(testCaseResult));
patchTestCaseResult(testCase.getFullyQualifiedName(), dateToTimestamp("2021-09-09"), patch, ADMIN_AUTH_HEADERS);
ResultList<TestCaseResult> testCaseResultResultListUpdated =
getTestCaseResults(
testCase.getFullyQualifiedName(),
TestUtils.dateToTimestamp("2021-09-09"),
TestUtils.dateToTimestamp("2021-09-09"),
ADMIN_AUTH_HEADERS);
assertEquals(
testCaseResultResultListUpdated.getData().get(0).getTestCaseFailureStatus().getTestCaseFailureStatusType(),
TestCaseFailureStatusType.Resolved);
assertEquals(
testCaseResultResultListUpdated.getData().get(0).getTestCaseFailureStatus().getTestCaseFailureReason(),
TestCaseFailureReason.FalsePositive);
}
@Test
void patch_testCaseResults_noChange(TestInfo test) throws IOException, ParseException {
CreateTestCase create =
createRequest(test)
.withEntityLink(TABLE_LINK_2)
.withTestSuite(TEST_SUITE1.getFullyQualifiedName())
.withTestDefinition(TEST_DEFINITION3.getFullyQualifiedName())
.withParameterValues(List.of(new TestCaseParameterValue().withValue("100").withName("missingCountValue")));
TestCase testCase = createAndCheckEntity(create, ADMIN_AUTH_HEADERS);
TestCaseResult testCaseResult =
new TestCaseResult()
.withResult("tested")
.withTestCaseStatus(TestCaseStatus.Success)
.withTimestamp(TestUtils.dateToTimestamp("2021-09-09"));
putTestCaseResult(testCase.getFullyQualifiedName(), testCaseResult, ADMIN_AUTH_HEADERS);
String original = JsonUtils.pojoToJson(testCaseResult);
testCaseResult.setTestCaseStatus(TestCaseStatus.Failed);
JsonPatch patch = JsonUtils.getJsonPatch(original, JsonUtils.pojoToJson(testCaseResult));
patchTestCaseResult(testCase.getFullyQualifiedName(), dateToTimestamp("2021-09-09"), patch, ADMIN_AUTH_HEADERS);
ResultList<TestCaseResult> testCaseResultResultListUpdated =
getTestCaseResults(
testCase.getFullyQualifiedName(),
TestUtils.dateToTimestamp("2021-09-09"),
TestUtils.dateToTimestamp("2021-09-09"),
ADMIN_AUTH_HEADERS);
// patching anything else than the test case failure status should not change anything
assertEquals(testCaseResultResultListUpdated.getData().get(0).getTestCaseStatus(), TestCaseStatus.Success);
}
@Test @Test
@Override @Override
public void delete_entity_as_non_admin_401(TestInfo test) throws HttpResponseException { public void delete_entity_as_non_admin_401(TestInfo test) throws HttpResponseException {
@ -572,6 +668,13 @@ public class TestCaseResourceTest extends EntityResourceTest<TestCase, CreateTes
return TestUtils.get(target, TestCaseResource.TestCaseList.class, authHeaders); return TestUtils.get(target, TestCaseResource.TestCaseList.class, authHeaders);
} }
private TestCaseResult patchTestCaseResult(
String testCaseFqn, Long timestamp, JsonPatch patch, Map<String, String> authHeaders)
throws HttpResponseException {
WebTarget target = getCollection().path("/" + testCaseFqn + "/testCaseResult/" + timestamp);
return TestUtils.patch(target, patch, TestCaseResult.class, authHeaders);
}
private void verifyTestCaseResults( private void verifyTestCaseResults(
ResultList<TestCaseResult> actualTestCaseResults, ResultList<TestCaseResult> actualTestCaseResults,
List<TestCaseResult> expectedTestCaseResults, List<TestCaseResult> expectedTestCaseResults,

View File

@ -336,6 +336,7 @@ public class TestSuiteResourceTest extends EntityResourceTest<TestSuite, CreateT
public TestSuite createExecutableTestSuite(CreateTestSuite createTestSuite, Map<String, String> authHeaders) public TestSuite createExecutableTestSuite(CreateTestSuite createTestSuite, Map<String, String> authHeaders)
throws IOException { throws IOException {
WebTarget target = getResource("dataQuality/testSuites/executable"); WebTarget target = getResource("dataQuality/testSuites/executable");
createTestSuite.setExecutableEntityReference(createTestSuite.getName());
return TestUtils.post(target, createTestSuite, TestSuite.class, authHeaders); return TestUtils.post(target, createTestSuite, TestSuite.class, authHeaders);
} }

View File

@ -16,7 +16,7 @@
}, },
"properties": { "properties": {
"name": { "name": {
"description": "Name that identifies this test suite. For executable testSuite, this should match the an entity FQN in the platform.", "description": "Name that identifies this test suite.",
"$ref": "#/definitions/testSuiteEntityName" "$ref": "#/definitions/testSuiteEntityName"
}, },
"displayName": { "displayName": {
@ -30,6 +30,10 @@
"owner": { "owner": {
"description": "Owner of this test suite", "description": "Owner of this test suite",
"$ref": "../../type/entityReference.json" "$ref": "../../type/entityReference.json"
},
"executableEntityReference": {
"description": "FQN of the entity the test suite is executed against.. Only applicable for executable test suites.",
"$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName"
} }
}, },
"required": ["name"], "required": ["name"],

View File

@ -18,7 +18,7 @@
"default": "TestSuite" "default": "TestSuite"
}, },
"entityFullyQualifiedName": { "entityFullyQualifiedName": {
"description": "Name of the test suite. For executable test suite it should match a fully qualified name of the table", "description": "Fully qualified name of the entity to be tested.",
"$ref": "../type/basic.json#/definitions/fullyQualifiedEntityName" "$ref": "../type/basic.json#/definitions/fullyQualifiedEntityName"
}, },
"profileSample": { "profileSample": {

View File

@ -62,6 +62,66 @@
"items": { "items": {
"$ref": "#/definitions/testResultValue" "$ref": "#/definitions/testResultValue"
} }
},
"testCaseFailureStatus": {
"description": "Schema to capture test case result.",
"javaType": "org.openmetadata.schema.tests.type.TestCaseFailureStatus",
"type": "object",
"properties":
{
"testCaseFailureStatusType": {
"description": "Status of Test Case Acknowledgement.",
"javaType": "org.openmetadata.schema.tests.type.TestCaseFailureStatusType",
"type": "string",
"enum": ["Ack", "New", "Resolved"],
"javaEnums": [
{
"name": "Ack"
},
{
"name": "New"
},
{
"name": "Resolved"
}
]
},
"testCaseFailureReason": {
"description": "Reason of Test Case resolution.",
"javaType": "org.openmetadata.schema.tests.type.TestCaseFailureReason",
"type": "string",
"enum": ["FalsePositive", "MissingData", "Duplicates", "OutOfBounds", "Other"],
"javaEnums": [
{
"name": "FalsePositive"
},
{
"name": "MissingData"
},
{
"name": "Duplicates"
},
{
"name": "OutOfBounds"
},
{
"name": "Other"
}
]
},
"testCaseFailureComment": {
"description": "Test case failure resolution comment.",
"type": "string"
},
"updatedBy": {
"description": "User who updated the test case failure status.",
"type": "string"
},
"updatedAt": {
"description": "Time when test case failure status was updated.",
"$ref": "../type/basic.json#/definitions/timestamp"
}
}
} }
}, },
"additionalProperties": false "additionalProperties": false

View File

@ -101,6 +101,10 @@
"description": "Indicates if the test suite is executable. Set on the backend.", "description": "Indicates if the test suite is executable. Set on the backend.",
"type": "boolean", "type": "boolean",
"default": false "default": false
},
"executableEntityReference": {
"description": "Entity reference the test suite is executed against. Only applicable if the test suite is executable.",
"$ref": "../type/entityReference.json"
} }
}, },
"required": ["name", "description"], "required": ["name", "description"],