mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-10-10 16:25:37 +00:00
* Implement Fetch DQ Test Cases by FollowedBy * Update generated TypeScript types * Fix wrong conflict resolution * Refactored into Optional --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
49bdf1a112
commit
a630ca2be6
@ -1491,6 +1491,8 @@ public abstract class EntityRepository<T extends EntityInterface> {
|
||||
|
||||
entity.setIncrementalChangeDescription(change);
|
||||
entity.setChangeDescription(change);
|
||||
// Populate followers before postUpdate to ensure propagation to children
|
||||
entity.setFollowers(getFollowers(entity));
|
||||
postUpdate(entity, entity);
|
||||
return new PutResponse<>(Status.OK, changeEvent, ENTITY_FIELDS_CHANGED);
|
||||
}
|
||||
@ -1966,6 +1968,8 @@ public abstract class EntityRepository<T extends EntityInterface> {
|
||||
|
||||
entity.setChangeDescription(change);
|
||||
entity.setIncrementalChangeDescription(change);
|
||||
// Populate followers before postUpdate to ensure propagation to children
|
||||
entity.setFollowers(getFollowers(entity));
|
||||
postUpdate(entity, entity);
|
||||
return new PutResponse<>(Status.OK, changeEvent, ENTITY_FIELDS_CHANGED);
|
||||
}
|
||||
@ -3060,6 +3064,16 @@ public abstract class EntityRepository<T extends EntityInterface> {
|
||||
}
|
||||
}
|
||||
|
||||
public final void inheritFollowers(T entity, Fields fields, EntityInterface parent) {
|
||||
if (fields.contains(FIELD_FOLLOWERS) && nullOrEmpty(entity.getFollowers()) && parent != null) {
|
||||
entity.setFollowers(
|
||||
Optional.ofNullable(parent.getFollowers())
|
||||
.filter(list -> !list.isEmpty())
|
||||
.orElse(Collections.emptyList()));
|
||||
listOrEmpty(entity.getFollowers()).forEach(follower -> follower.setInherited(true));
|
||||
}
|
||||
}
|
||||
|
||||
public final void inheritOwners(T entity, Fields fields, EntityInterface parent) {
|
||||
if (fields.contains(FIELD_OWNERS) && nullOrEmpty(entity.getOwners()) && parent != null) {
|
||||
entity.setOwners(getInheritedOwners(entity, fields, parent));
|
||||
|
@ -366,12 +366,15 @@ public class TestCaseRepository extends EntityRepository<TestCase> {
|
||||
// Inherit from the table/column
|
||||
EntityInterface tableOrColumn =
|
||||
Entity.getEntity(
|
||||
EntityLink.parse(testCase.getEntityLink()), "owners,domains,tags,columns", ALL);
|
||||
EntityLink.parse(testCase.getEntityLink()),
|
||||
"owners,domains,tags,columns,followers",
|
||||
ALL);
|
||||
if (tableOrColumn != null) {
|
||||
inheritOwners(testCase, fields, tableOrColumn);
|
||||
inheritDomains(testCase, fields, tableOrColumn);
|
||||
if (tableOrColumn instanceof Table) {
|
||||
inheritTags(testCase, fields, (Table) tableOrColumn);
|
||||
inheritFollowers(testCase, fields, (Table) tableOrColumn);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -95,9 +95,9 @@ public class TestCaseResource extends EntityResource<TestCase, TestCaseRepositor
|
||||
private final TestCaseMapper mapper = new TestCaseMapper();
|
||||
private final TestCaseResultMapper testCaseResultMapper = new TestCaseResultMapper();
|
||||
static final String FIELDS =
|
||||
"owners,reviewers,entityStatus,testSuite,testDefinition,testSuites,incidentId,domains,tags";
|
||||
"owners,reviewers,entityStatus,testSuite,testDefinition,testSuites,incidentId,domains,tags,followers";
|
||||
static final String SEARCH_FIELDS_EXCLUDE =
|
||||
"testPlatforms,table,database,databaseSchema,service,testSuite,dataQualityDimension,testCaseType,originEntityFQN";
|
||||
"testPlatforms,table,database,databaseSchema,service,testSuite,dataQualityDimension,testCaseType,originEntityFQN,followers";
|
||||
|
||||
@Override
|
||||
public TestCase addHref(UriInfo uriInfo, TestCase test) {
|
||||
@ -416,81 +416,52 @@ public class TestCaseResource extends EntityResource<TestCase, TestCaseRepositor
|
||||
description = "Filter test cases by the user who created them",
|
||||
schema = @Schema(type = "string"))
|
||||
@QueryParam("createdBy")
|
||||
String createdBy)
|
||||
String createdBy,
|
||||
@Parameter(
|
||||
description = "Filter test cases by entities followed by a user",
|
||||
schema = @Schema(type = "string"))
|
||||
@QueryParam("followedBy")
|
||||
String followedBy)
|
||||
throws IOException {
|
||||
if ((startTimestamp == null && endTimestamp != null)
|
||||
|| (startTimestamp != null && endTimestamp == null)) {
|
||||
throw new IllegalArgumentException("startTimestamp and endTimestamp must be used together");
|
||||
}
|
||||
// Validate parameters
|
||||
validateTimestamps(startTimestamp, endTimestamp);
|
||||
|
||||
// Build search filters
|
||||
SearchSortFilter searchSortFilter =
|
||||
new SearchSortFilter(sortField, sortType, sortNestedPath, sortNestedMode);
|
||||
SearchListFilter searchListFilter = new SearchListFilter(include);
|
||||
searchListFilter.addQueryParam("testSuiteId", testSuiteId);
|
||||
searchListFilter.addQueryParam("includeAllTests", includeAllTests.toString());
|
||||
searchListFilter.addQueryParam("testCaseStatus", status);
|
||||
searchListFilter.addQueryParam("testCaseType", type);
|
||||
searchListFilter.addQueryParam("testPlatforms", testPlatforms);
|
||||
searchListFilter.addQueryParam("dataQualityDimension", dataQualityDimension);
|
||||
searchListFilter.addQueryParam("q", q);
|
||||
searchListFilter.addQueryParam("excludeFields", SEARCH_FIELDS_EXCLUDE);
|
||||
searchListFilter.addQueryParam("includeFields", includeFields);
|
||||
searchListFilter.addQueryParam("domains", domain);
|
||||
searchListFilter.addQueryParam("tags", tags);
|
||||
searchListFilter.addQueryParam("tier", tier);
|
||||
searchListFilter.addQueryParam("serviceName", serviceName);
|
||||
searchListFilter.addQueryParam("createdBy", createdBy);
|
||||
if (!nullOrEmpty(owner)) {
|
||||
EntityInterface entity;
|
||||
StringBuilder owners = new StringBuilder();
|
||||
try {
|
||||
User user = Entity.getEntityByName(Entity.USER, owner, "teams", ALL);
|
||||
owners.append(user.getId().toString());
|
||||
if (!nullOrEmpty(user.getTeams())) {
|
||||
owners
|
||||
.append(",")
|
||||
.append(
|
||||
user.getTeams().stream()
|
||||
.map(t -> t.getId().toString())
|
||||
.collect(Collectors.joining(",")));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// If the owner is not a user, then we'll try to get team
|
||||
entity = Entity.getEntityByName(Entity.TEAM, owner, "", ALL);
|
||||
owners.append(entity.getId().toString());
|
||||
}
|
||||
searchListFilter.addQueryParam("owners", owners.toString());
|
||||
}
|
||||
|
||||
if (startTimestamp != null) {
|
||||
if (startTimestamp > endTimestamp) {
|
||||
throw new IllegalArgumentException("startTimestamp must be less than endTimestamp");
|
||||
}
|
||||
searchListFilter.addQueryParam("startTimestamp", startTimestamp.toString());
|
||||
searchListFilter.addQueryParam("endTimestamp", endTimestamp.toString());
|
||||
}
|
||||
|
||||
ResourceContextInterface resourceContextInterface =
|
||||
getResourceContext(entityLink, searchListFilter);
|
||||
// Override OperationContext to change the entity to table and operation from VIEW_ALL to
|
||||
// VIEW_TESTS
|
||||
OperationContext operationContext =
|
||||
new OperationContext(Entity.TABLE, MetadataOperation.VIEW_TESTS);
|
||||
Fields fields = getFields(fieldsParam);
|
||||
|
||||
ResultList<TestCase> tests =
|
||||
super.listInternalFromSearch(
|
||||
uriInfo,
|
||||
securityContext,
|
||||
fields,
|
||||
searchListFilter,
|
||||
limit,
|
||||
offset,
|
||||
searchSortFilter,
|
||||
SearchListFilter searchListFilter =
|
||||
buildSearchListFilter(
|
||||
include,
|
||||
testSuiteId,
|
||||
includeAllTests,
|
||||
status,
|
||||
type,
|
||||
testPlatforms,
|
||||
dataQualityDimension,
|
||||
q,
|
||||
queryString,
|
||||
operationContext,
|
||||
resourceContextInterface);
|
||||
return PIIMasker.getTestCases(tests, authorizer, securityContext);
|
||||
includeFields,
|
||||
domain,
|
||||
tags,
|
||||
tier,
|
||||
serviceName,
|
||||
createdBy,
|
||||
owner,
|
||||
followedBy,
|
||||
startTimestamp,
|
||||
endTimestamp);
|
||||
|
||||
// Execute search
|
||||
return executeTestCaseSearch(
|
||||
uriInfo,
|
||||
securityContext,
|
||||
fieldsParam,
|
||||
entityLink,
|
||||
searchListFilter,
|
||||
searchSortFilter,
|
||||
limit,
|
||||
offset,
|
||||
q,
|
||||
queryString);
|
||||
}
|
||||
|
||||
@GET
|
||||
@ -1157,4 +1128,140 @@ public class TestCaseResource extends EntityResource<TestCase, TestCaseRepositor
|
||||
}
|
||||
return resourceContext;
|
||||
}
|
||||
|
||||
private static String resolveUserOrTeamIds(String userOrTeamName, boolean includeTeamMembers) {
|
||||
if (nullOrEmpty(userOrTeamName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder ids = new StringBuilder();
|
||||
try {
|
||||
// Try to resolve as a user first
|
||||
User user =
|
||||
Entity.getEntityByName(
|
||||
Entity.USER, userOrTeamName, includeTeamMembers ? "teams" : "", ALL);
|
||||
ids.append(user.getId().toString());
|
||||
|
||||
// If includeTeamMembers is true and user has teams, add team IDs
|
||||
if (includeTeamMembers && !nullOrEmpty(user.getTeams())) {
|
||||
ids.append(",")
|
||||
.append(
|
||||
user.getTeams().stream()
|
||||
.map(t -> t.getId().toString())
|
||||
.collect(Collectors.joining(",")));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// If not a user, try to resolve as a team
|
||||
EntityInterface entity = Entity.getEntityByName(Entity.TEAM, userOrTeamName, "", ALL);
|
||||
ids.append(entity.getId().toString());
|
||||
}
|
||||
|
||||
return ids.toString();
|
||||
}
|
||||
|
||||
private static void validateTimestamps(Long startTimestamp, Long endTimestamp) {
|
||||
if ((startTimestamp == null && endTimestamp != null)
|
||||
|| (startTimestamp != null && endTimestamp == null)) {
|
||||
throw new IllegalArgumentException("startTimestamp and endTimestamp must be used together");
|
||||
}
|
||||
if (startTimestamp != null && startTimestamp > endTimestamp) {
|
||||
throw new IllegalArgumentException("startTimestamp must be less than endTimestamp");
|
||||
}
|
||||
}
|
||||
|
||||
private static SearchListFilter buildSearchListFilter(
|
||||
Include include,
|
||||
String testSuiteId,
|
||||
Boolean includeAllTests,
|
||||
String status,
|
||||
String type,
|
||||
String testPlatforms,
|
||||
String dataQualityDimension,
|
||||
String q,
|
||||
String includeFields,
|
||||
String domain,
|
||||
String tags,
|
||||
String tier,
|
||||
String serviceName,
|
||||
String createdBy,
|
||||
String owner,
|
||||
String followedBy,
|
||||
Long startTimestamp,
|
||||
Long endTimestamp) {
|
||||
|
||||
SearchListFilter searchListFilter = new SearchListFilter(include);
|
||||
|
||||
// Add basic parameters
|
||||
searchListFilter.addQueryParam("testSuiteId", testSuiteId);
|
||||
searchListFilter.addQueryParam("includeAllTests", includeAllTests.toString());
|
||||
searchListFilter.addQueryParam("testCaseStatus", status);
|
||||
searchListFilter.addQueryParam("testCaseType", type);
|
||||
searchListFilter.addQueryParam("testPlatforms", testPlatforms);
|
||||
searchListFilter.addQueryParam("dataQualityDimension", dataQualityDimension);
|
||||
searchListFilter.addQueryParam("q", q);
|
||||
searchListFilter.addQueryParam("excludeFields", SEARCH_FIELDS_EXCLUDE);
|
||||
searchListFilter.addQueryParam("includeFields", includeFields);
|
||||
searchListFilter.addQueryParam("domains", domain);
|
||||
searchListFilter.addQueryParam("tags", tags);
|
||||
searchListFilter.addQueryParam("tier", tier);
|
||||
searchListFilter.addQueryParam("serviceName", serviceName);
|
||||
searchListFilter.addQueryParam("createdBy", createdBy);
|
||||
|
||||
// Handle owner and followedBy parameters
|
||||
if (!nullOrEmpty(owner)) {
|
||||
String ownerIds = resolveUserOrTeamIds(owner, true); // include team members
|
||||
searchListFilter.addQueryParam("owners", ownerIds);
|
||||
}
|
||||
if (!nullOrEmpty(followedBy)) {
|
||||
String followerIds = resolveUserOrTeamIds(followedBy, false); // don't include team members
|
||||
searchListFilter.addQueryParam("followedBy", followerIds);
|
||||
}
|
||||
|
||||
// Add timestamp parameters
|
||||
if (startTimestamp != null) {
|
||||
searchListFilter.addQueryParam("startTimestamp", startTimestamp.toString());
|
||||
searchListFilter.addQueryParam("endTimestamp", endTimestamp.toString());
|
||||
}
|
||||
|
||||
return searchListFilter;
|
||||
}
|
||||
|
||||
private ResultList<TestCase> executeTestCaseSearch(
|
||||
UriInfo uriInfo,
|
||||
SecurityContext securityContext,
|
||||
String fieldsParam,
|
||||
String entityLink,
|
||||
SearchListFilter searchListFilter,
|
||||
SearchSortFilter searchSortFilter,
|
||||
int limit,
|
||||
int offset,
|
||||
String q,
|
||||
String queryString)
|
||||
throws IOException {
|
||||
|
||||
ResourceContextInterface resourceContextInterface =
|
||||
getResourceContext(entityLink, searchListFilter);
|
||||
|
||||
// Override OperationContext to change the entity to table and operation from VIEW_ALL to
|
||||
// VIEW_TESTS
|
||||
OperationContext operationContext =
|
||||
new OperationContext(Entity.TABLE, MetadataOperation.VIEW_TESTS);
|
||||
Fields fields = getFields(fieldsParam);
|
||||
|
||||
ResultList<TestCase> tests =
|
||||
super.listInternalFromSearch(
|
||||
uriInfo,
|
||||
securityContext,
|
||||
fields,
|
||||
searchListFilter,
|
||||
limit,
|
||||
offset,
|
||||
searchSortFilter,
|
||||
q,
|
||||
queryString,
|
||||
operationContext,
|
||||
resourceContextInterface);
|
||||
|
||||
return PIIMasker.getTestCases(tests, authorizer, securityContext);
|
||||
}
|
||||
}
|
||||
|
@ -231,6 +231,53 @@ public interface SearchClient<T> {
|
||||
}
|
||||
""";
|
||||
|
||||
// Script for propagating followers to TestCases from their parent tables.
|
||||
// TestCases can only have inherited followers (no direct followers allowed),
|
||||
// so we always replace the entire followers list when propagating.
|
||||
// Followers are stored as UUID strings in the search index for efficiency.
|
||||
// This script only applies to TestCases - does nothing for other entity types.
|
||||
String ADD_FOLLOWERS_SCRIPT =
|
||||
"""
|
||||
if (ctx._source.containsKey('entityType') && ctx._source.entityType == 'testCase') {
|
||||
// TestCases can only have inherited followers - always replace
|
||||
if (params.containsKey('updatedFollowers') && params.updatedFollowers != null) {
|
||||
List followerIds = new ArrayList();
|
||||
for (def follower : params.updatedFollowers) {
|
||||
if (follower != null && follower.containsKey('id')) {
|
||||
followerIds.add(follower.id.toString());
|
||||
}
|
||||
}
|
||||
ctx._source.followers = followerIds;
|
||||
}
|
||||
}
|
||||
// Do nothing for other entity types
|
||||
""";
|
||||
|
||||
// Script for removing followers from TestCases when removed from their parent tables.
|
||||
// TestCases can only have inherited followers, so when the parent loses followers,
|
||||
// we need to update the TestCase's follower list accordingly.
|
||||
// Note: deletedFollowers contains the REMAINING followers after deletion, not the deleted ones.
|
||||
// This script only applies to TestCases - does nothing for other entity types.
|
||||
String REMOVE_FOLLOWERS_SCRIPT =
|
||||
"""
|
||||
if (ctx._source.containsKey('entityType') && ctx._source.entityType == 'testCase') {
|
||||
// For TestCases, replace with the updated follower list (already has removed followers filtered out)
|
||||
if (params.containsKey('deletedFollowers') && params.deletedFollowers != null) {
|
||||
List followerIds = new ArrayList();
|
||||
for (def follower : params.deletedFollowers) {
|
||||
if (follower != null && follower.containsKey('id')) {
|
||||
followerIds.add(follower.id.toString());
|
||||
}
|
||||
}
|
||||
ctx._source.followers = followerIds;
|
||||
} else {
|
||||
// If no followers remain, clear the list
|
||||
ctx._source.followers = new ArrayList();
|
||||
}
|
||||
}
|
||||
// Do nothing for other entity types
|
||||
""";
|
||||
|
||||
String UPDATE_TAGS_FIELD_SCRIPT =
|
||||
"""
|
||||
if (ctx._source.tags != null) {
|
||||
|
@ -21,6 +21,18 @@ public class SearchListFilter extends Filter<SearchListFilter> {
|
||||
|
||||
static final String SEARCH_LIST_FILTER_EXCLUDE = "fqnParts,entityType,suggest";
|
||||
|
||||
// Elasticsearch field name constants
|
||||
private static final String FIELD_DELETED = "deleted";
|
||||
private static final String FIELD_OWNERS_ID = "owners.id";
|
||||
private static final String FIELD_CREATED_BY = "createdBy";
|
||||
private static final String FIELD_DOMAINS_FQN = "domains.fullyQualifiedName";
|
||||
private static final String FIELD_SERVICE_NAME = "service.name";
|
||||
private static final String FIELD_TEST_CASE_STATUS = "testCaseResult.testCaseStatus";
|
||||
private static final String FIELD_TEST_PLATFORMS = "testPlatforms";
|
||||
private static final String FIELD_FOLLOWERS_KEYWORD = "followers.keyword";
|
||||
private static final String FIELD_TEST_STATUS = "testCaseStatus";
|
||||
private static final String FIELD_BASIC = "basic";
|
||||
|
||||
@Override
|
||||
public String getCondition(String entityType) {
|
||||
String conditionFilter = buildConditionFilter(entityType);
|
||||
@ -115,7 +127,7 @@ public class SearchListFilter extends Filter<SearchListFilter> {
|
||||
String domain = getQueryParam("domains");
|
||||
if (!nullOrEmpty(domain)) {
|
||||
return String.format(
|
||||
"{\"term\": {\"domains.fullyQualifiedName\": \"%s\"}}", escapeDoubleQuotes(domain));
|
||||
"{\"term\": {\"%s\": \"%s\"}}", FIELD_DOMAINS_FQN, escapeDoubleQuotes(domain));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@ -129,7 +141,8 @@ public class SearchListFilter extends Filter<SearchListFilter> {
|
||||
}
|
||||
String deleted = "";
|
||||
if (include != Include.ALL && supportsDeleted) {
|
||||
deleted = String.format("{\"term\": {\"deleted\": \"%s\"}}", include == Include.DELETED);
|
||||
deleted =
|
||||
String.format("{\"term\": {\"%s\": \"%s\"}}", FIELD_DELETED, include == Include.DELETED);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
@ -139,7 +152,7 @@ public class SearchListFilter extends Filter<SearchListFilter> {
|
||||
if (!nullOrEmpty(owners)) {
|
||||
String ownersList =
|
||||
Arrays.stream(owners.split(",")).collect(Collectors.joining("\", \"", "\"", "\""));
|
||||
return String.format("{\"terms\": {\"owners.id\": [%s]}}", ownersList);
|
||||
return String.format("{\"terms\": {\"%s\": [%s]}}", FIELD_OWNERS_ID, ownersList);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@ -147,7 +160,8 @@ public class SearchListFilter extends Filter<SearchListFilter> {
|
||||
private String getCreatedByCondition() {
|
||||
String createdBy = getQueryParam("createdBy");
|
||||
if (!nullOrEmpty(createdBy)) {
|
||||
return String.format("{\"term\": {\"createdBy\": \"%s\"}}", escapeDoubleQuotes(createdBy));
|
||||
return String.format(
|
||||
"{\"term\": {\"%s\": \"%s\"}}", FIELD_CREATED_BY, escapeDoubleQuotes(createdBy));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@ -200,6 +214,7 @@ public class SearchListFilter extends Filter<SearchListFilter> {
|
||||
String tier = getQueryParam("tier");
|
||||
String serviceName = getQueryParam("serviceName");
|
||||
String dataQualityDimension = getQueryParam("dataQualityDimension");
|
||||
String followedBy = getQueryParam("followedBy");
|
||||
|
||||
if (tags != null) {
|
||||
String tagsList =
|
||||
@ -221,7 +236,8 @@ public class SearchListFilter extends Filter<SearchListFilter> {
|
||||
|
||||
if (serviceName != null) {
|
||||
conditions.add(
|
||||
String.format("{\"term\": {\"service.name\": \"%s\"}}", escapeDoubleQuotes(serviceName)));
|
||||
String.format(
|
||||
"{\"term\": {\"%s\": \"%s\"}}", FIELD_SERVICE_NAME, escapeDoubleQuotes(serviceName)));
|
||||
}
|
||||
|
||||
if (entityFQN != null) {
|
||||
@ -235,8 +251,7 @@ public class SearchListFilter extends Filter<SearchListFilter> {
|
||||
if (testSuiteId != null) conditions.add(getTestSuiteIdCondition(testSuiteId));
|
||||
|
||||
if (status != null) {
|
||||
conditions.add(
|
||||
String.format("{\"term\": {\"testCaseResult.testCaseStatus\": \"%s\"}}", status));
|
||||
conditions.add(String.format("{\"term\": {\"%s\": \"%s\"}}", FIELD_TEST_CASE_STATUS, status));
|
||||
}
|
||||
|
||||
if (type != null) conditions.add(getTestCaseTypeCondition(type, "entityLink"));
|
||||
@ -244,7 +259,7 @@ public class SearchListFilter extends Filter<SearchListFilter> {
|
||||
if (testPlatform != null) {
|
||||
String platforms =
|
||||
Arrays.stream(testPlatform.split(",")).collect(Collectors.joining("\", \"", "\"", "\""));
|
||||
conditions.add(String.format("{\"terms\": {\"testPlatforms\": [%s]}}", platforms));
|
||||
conditions.add(String.format("{\"terms\": {\"%s\": [%s]}}", FIELD_TEST_PLATFORMS, platforms));
|
||||
}
|
||||
|
||||
if (startTimestamp != null && endTimestamp != null) {
|
||||
@ -258,6 +273,11 @@ public class SearchListFilter extends Filter<SearchListFilter> {
|
||||
conditions.add(
|
||||
getDataQualityDimensionCondition(dataQualityDimension, "dataQualityDimension"));
|
||||
|
||||
if (followedBy != null) {
|
||||
conditions.add(
|
||||
String.format("{\"term\": {\"%s\": \"%s\"}}", FIELD_FOLLOWERS_KEYWORD, followedBy));
|
||||
}
|
||||
|
||||
return addCondition(conditions);
|
||||
}
|
||||
|
||||
@ -289,7 +309,8 @@ public class SearchListFilter extends Filter<SearchListFilter> {
|
||||
escapeDoubleQuotes(testCaseFQN)));
|
||||
}
|
||||
if (testCaseStatus != null)
|
||||
conditions.add(String.format("{\"term\": {\"testCaseStatus\": \"%s\"}}", testCaseStatus));
|
||||
conditions.add(
|
||||
String.format("{\"term\": {\"%s\": \"%s\"}}", FIELD_TEST_STATUS, testCaseStatus));
|
||||
if (type != null) conditions.add(getTestCaseTypeCondition(type, "testCase.entityLink"));
|
||||
if (testSuiteId != null) conditions.add(getTestSuiteIdCondition(testSuiteId));
|
||||
if (dataQualityDimension != null)
|
||||
@ -308,7 +329,7 @@ public class SearchListFilter extends Filter<SearchListFilter> {
|
||||
|
||||
if (testSuiteType != null) {
|
||||
boolean basic = !testSuiteType.equals("logical");
|
||||
conditions.add(String.format("{\"term\": {\"basic\": \"%s\"}}", basic));
|
||||
conditions.add(String.format("{\"term\": {\"%s\": \"%s\"}}", FIELD_BASIC, basic));
|
||||
}
|
||||
|
||||
if (!includeEmptyTestSuites) {
|
||||
|
@ -16,6 +16,7 @@ import static org.openmetadata.service.Entity.RAW_COST_ANALYSIS_REPORT_DATA;
|
||||
import static org.openmetadata.service.Entity.WEB_ANALYTIC_ENTITY_VIEW_REPORT_DATA;
|
||||
import static org.openmetadata.service.Entity.WEB_ANALYTIC_USER_ACTIVITY_REPORT_DATA;
|
||||
import static org.openmetadata.service.search.SearchClient.ADD_DOMAINS_SCRIPT;
|
||||
import static org.openmetadata.service.search.SearchClient.ADD_FOLLOWERS_SCRIPT;
|
||||
import static org.openmetadata.service.search.SearchClient.ADD_OWNERS_SCRIPT;
|
||||
import static org.openmetadata.service.search.SearchClient.DATA_ASSET_SEARCH_ALIAS;
|
||||
import static org.openmetadata.service.search.SearchClient.DEFAULT_UPDATE_SCRIPT;
|
||||
@ -28,6 +29,7 @@ import static org.openmetadata.service.search.SearchClient.REMOVE_DATA_PRODUCTS_
|
||||
import static org.openmetadata.service.search.SearchClient.REMOVE_DOMAINS_CHILDREN_SCRIPT;
|
||||
import static org.openmetadata.service.search.SearchClient.REMOVE_DOMAINS_SCRIPT;
|
||||
import static org.openmetadata.service.search.SearchClient.REMOVE_ENTITY_RELATIONSHIP;
|
||||
import static org.openmetadata.service.search.SearchClient.REMOVE_FOLLOWERS_SCRIPT;
|
||||
import static org.openmetadata.service.search.SearchClient.REMOVE_OWNERS_SCRIPT;
|
||||
import static org.openmetadata.service.search.SearchClient.REMOVE_PROPAGATED_ENTITY_REFERENCE_FIELD_SCRIPT;
|
||||
import static org.openmetadata.service.search.SearchClient.REMOVE_PROPAGATED_FIELD_SCRIPT;
|
||||
@ -139,6 +141,7 @@ public class SearchRepository {
|
||||
List.of(
|
||||
FIELD_OWNERS,
|
||||
FIELD_DOMAINS,
|
||||
FIELD_FOLLOWERS,
|
||||
Entity.FIELD_DISABLED,
|
||||
Entity.FIELD_TEST_SUITES,
|
||||
FIELD_DISPLAY_NAME);
|
||||
@ -781,6 +784,12 @@ public class SearchRepository {
|
||||
fieldData.put("deletedDomains", inheritedDomains);
|
||||
scriptTxt.append(REMOVE_DOMAINS_SCRIPT);
|
||||
scriptTxt.append(" ");
|
||||
} else if (field.getName().equals(FIELD_FOLLOWERS)) {
|
||||
List<EntityReference> inheritedFollowers =
|
||||
copyWithInheritedFlag(entity.getFollowers());
|
||||
fieldData.put("deletedFollowers", inheritedFollowers);
|
||||
scriptTxt.append(REMOVE_FOLLOWERS_SCRIPT);
|
||||
scriptTxt.append(" ");
|
||||
} else {
|
||||
EntityReference entityReference =
|
||||
JsonUtils.readValue(field.getOldValue().toString(), EntityReference.class);
|
||||
@ -852,6 +861,11 @@ public class SearchRepository {
|
||||
}
|
||||
fieldData.put("updatedDomains", inheritedDomains);
|
||||
scriptTxt.append(ADD_DOMAINS_SCRIPT);
|
||||
} else if (field.getName().equals(FIELD_FOLLOWERS)) {
|
||||
List<EntityReference> inheritedFollowers =
|
||||
copyWithInheritedFlag(entity.getFollowers());
|
||||
fieldData.put("updatedFollowers", inheritedFollowers);
|
||||
scriptTxt.append(ADD_FOLLOWERS_SCRIPT);
|
||||
} else {
|
||||
EntityReference entityReference =
|
||||
JsonUtils.readValue(field.getNewValue().toString(), EntityReference.class);
|
||||
@ -1497,4 +1511,14 @@ public class SearchRepository {
|
||||
return searchClient.getSchemaEntityRelationship(
|
||||
schemaFqn, queryFilter, includeSourceFields, offset, limit, from, size, deleted);
|
||||
}
|
||||
|
||||
private static List<EntityReference> copyWithInheritedFlag(List<EntityReference> references) {
|
||||
if (references == null || references.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<EntityReference> inheritedReferences =
|
||||
JsonUtils.deepCopyList(references, EntityReference.class);
|
||||
inheritedReferences.forEach(ref -> ref.setInherited(true));
|
||||
return inheritedReferences;
|
||||
}
|
||||
}
|
||||
|
@ -264,7 +264,7 @@ public abstract class EntityResourceTest<T extends EntityInterface, K extends Cr
|
||||
private final String allFields;
|
||||
private final String
|
||||
systemEntityName; // System entity provided by the system that can't be deleted
|
||||
protected final boolean supportsFollowers;
|
||||
protected boolean supportsFollowers;
|
||||
protected final boolean supportsVotes;
|
||||
protected boolean supportsOwners;
|
||||
protected boolean supportsTags;
|
||||
@ -4160,6 +4160,45 @@ public abstract class EntityResourceTest<T extends EntityInterface, K extends Cr
|
||||
return responseMap.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a specific field to have an expected value in the search index.
|
||||
* This is useful for waiting for inherited fields to propagate.
|
||||
*
|
||||
* @param entityId The entity ID to check
|
||||
* @param entityType The entity type
|
||||
* @param fieldName The field name to check
|
||||
* @param expectedValue The expected value of the field
|
||||
*/
|
||||
public static void waitForFieldInSearchIndex(
|
||||
UUID entityId, String entityType, String fieldName, Object expectedValue) {
|
||||
Awaitility.await(String.format("Wait for field '%s' to be updated in search index", fieldName))
|
||||
.ignoreExceptions()
|
||||
.pollInterval(Duration.ofMillis(500))
|
||||
.atMost(Duration.ofSeconds(30))
|
||||
.until(
|
||||
() -> {
|
||||
Map<String, Object> doc = getEntityDocumentFromSearch(entityId, entityType);
|
||||
Object actualValue = doc.get(fieldName);
|
||||
|
||||
// Handle null comparisons
|
||||
if (expectedValue == null) {
|
||||
return actualValue == null;
|
||||
}
|
||||
|
||||
// For collections, compare contents
|
||||
if (expectedValue instanceof List && actualValue instanceof List) {
|
||||
List<?> expectedList = (List<?>) expectedValue;
|
||||
List<?> actualList = (List<?>) actualValue;
|
||||
return expectedList.size() == actualList.size()
|
||||
&& expectedList.containsAll(actualList)
|
||||
&& actualList.containsAll(expectedList);
|
||||
}
|
||||
|
||||
// For other types, use equals
|
||||
return expectedValue.equals(actualValue);
|
||||
});
|
||||
}
|
||||
|
||||
public static Map<String, Object> getEntityDocumentFromSearch(UUID entityId, String entityType)
|
||||
throws HttpResponseException {
|
||||
IndexMapping indexMapping = Entity.getSearchRepository().getIndexMapping(entityType);
|
||||
|
@ -195,6 +195,8 @@ public class TestCaseResourceTest extends EntityResourceTest<TestCase, CreateTes
|
||||
"dataQuality/testCases",
|
||||
TestCaseResource.FIELDS);
|
||||
supportsTags = false; // Test cases do not support setting tags directly (inherits from Entity)
|
||||
supportsFollowers =
|
||||
false; // Test cases do not support setting followers directly (inherits from parent table)
|
||||
testCaseResultsCollectionName = "dataQuality/testCases/testCaseResults";
|
||||
supportsEtag = false;
|
||||
}
|
||||
@ -3155,6 +3157,21 @@ public class TestCaseResourceTest extends EntityResourceTest<TestCase, CreateTes
|
||||
return TestUtils.get(target, TestCaseResultResource.TestCaseResultList.class, authHeader);
|
||||
}
|
||||
|
||||
public ResultList<TestCase> listTestCasesFromSearch(
|
||||
Map<String, String> queryParams,
|
||||
Integer limit,
|
||||
Integer offset,
|
||||
Map<String, String> authHeader)
|
||||
throws HttpResponseException {
|
||||
WebTarget target = getCollection().path("/search/list");
|
||||
for (Map.Entry<String, String> entry : queryParams.entrySet()) {
|
||||
target = target.queryParam(entry.getKey(), entry.getValue());
|
||||
}
|
||||
target = limit != null ? target.queryParam("limit", limit) : target;
|
||||
target = offset != null ? target.queryParam("offset", offset) : target;
|
||||
return TestUtils.get(target, TestCaseResource.TestCaseList.class, authHeader);
|
||||
}
|
||||
|
||||
protected void validateListTestCaseResultsFromSearchWithPagination(
|
||||
Map<String, String> queryParams, Integer maxEntities, String path) throws IOException {
|
||||
// List all entities and use it for checking pagination
|
||||
@ -4139,6 +4156,165 @@ public class TestCaseResourceTest extends EntityResourceTest<TestCase, CreateTes
|
||||
assertNull(nullValueResult.getFailedRows(), "Failed rows should be null when not set");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_testCaseFollowerInheritance(TestInfo testInfo)
|
||||
throws IOException, InterruptedException {
|
||||
// Create a table with a follower
|
||||
TableResourceTest tableResourceTest = new TableResourceTest();
|
||||
CreateTable tableReq =
|
||||
tableResourceTest
|
||||
.createRequest(testInfo)
|
||||
.withColumns(
|
||||
List.of(new Column().withName(C1).withDisplayName("c1").withDataType(BIGINT)));
|
||||
Table table = tableResourceTest.createAndCheckEntity(tableReq, ADMIN_AUTH_HEADERS);
|
||||
|
||||
// Add USER1 as follower to the table
|
||||
tableResourceTest.addFollower(table.getId(), USER1_REF.getId(), OK, ADMIN_AUTH_HEADERS);
|
||||
|
||||
// Create a test case for this table
|
||||
CreateTestCase create =
|
||||
createRequest(testInfo)
|
||||
.withEntityLink(String.format("<#E::table::%s>", table.getFullyQualifiedName()))
|
||||
.withTestDefinition(TEST_DEFINITION4.getFullyQualifiedName())
|
||||
.withParameterValues(
|
||||
List.of(new TestCaseParameterValue().withValue("10").withName("minValue")));
|
||||
TestCase testCase = createAndCheckEntity(create, ADMIN_AUTH_HEADERS);
|
||||
|
||||
// Wait for search index sync
|
||||
Map<String, Object> sourceAsMap =
|
||||
waitForSyncAndGetFromSearchIndex(
|
||||
testCase.getUpdatedAt(), testCase.getId(), Entity.TEST_CASE);
|
||||
|
||||
// Verify the test case inherited the follower from the table
|
||||
List<String> followers = (List<String>) sourceAsMap.get("followers");
|
||||
assertNotNull(followers);
|
||||
assertEquals(1, followers.size());
|
||||
assertEquals(USER1.getId().toString(), followers.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_listTestCasesWithFollowedByFilter(TestInfo testInfo)
|
||||
throws IOException, InterruptedException {
|
||||
// Create two tables with different followers
|
||||
TableResourceTest tableResourceTest = new TableResourceTest();
|
||||
CreateTable tableReq1 =
|
||||
tableResourceTest
|
||||
.createRequest(testInfo, 1)
|
||||
.withColumns(
|
||||
List.of(new Column().withName(C1).withDisplayName("c1").withDataType(BIGINT)));
|
||||
Table table1 = tableResourceTest.createAndCheckEntity(tableReq1, ADMIN_AUTH_HEADERS);
|
||||
|
||||
CreateTable tableReq2 =
|
||||
tableResourceTest
|
||||
.createRequest(testInfo, 2)
|
||||
.withColumns(
|
||||
List.of(new Column().withName(C1).withDisplayName("c1").withDataType(BIGINT)));
|
||||
Table table2 = tableResourceTest.createAndCheckEntity(tableReq2, ADMIN_AUTH_HEADERS);
|
||||
|
||||
// Add USER1 as follower to table1
|
||||
tableResourceTest.addFollower(table1.getId(), USER1_REF.getId(), OK, ADMIN_AUTH_HEADERS);
|
||||
|
||||
// Add USER2 as follower to table2
|
||||
tableResourceTest.addFollower(table2.getId(), USER2_REF.getId(), OK, ADMIN_AUTH_HEADERS);
|
||||
|
||||
// Create test cases for both tables
|
||||
CreateTestCase create1 =
|
||||
createRequest(testInfo, 1)
|
||||
.withEntityLink(String.format("<#E::table::%s>", table1.getFullyQualifiedName()))
|
||||
.withTestDefinition(TEST_DEFINITION4.getFullyQualifiedName())
|
||||
.withParameterValues(
|
||||
List.of(new TestCaseParameterValue().withValue("10").withName("minValue")));
|
||||
TestCase testCase1 = createAndCheckEntity(create1, ADMIN_AUTH_HEADERS);
|
||||
|
||||
CreateTestCase create2 =
|
||||
createRequest(testInfo, 2)
|
||||
.withEntityLink(String.format("<#E::table::%s>", table2.getFullyQualifiedName()))
|
||||
.withTestDefinition(TEST_DEFINITION4.getFullyQualifiedName())
|
||||
.withParameterValues(
|
||||
List.of(new TestCaseParameterValue().withValue("10").withName("minValue")));
|
||||
TestCase testCase2 = createAndCheckEntity(create2, ADMIN_AUTH_HEADERS);
|
||||
|
||||
// Wait for test cases to be properly indexed with their inherited followers
|
||||
// TestCase1 should inherit USER1 from table1
|
||||
waitForFieldInSearchIndex(
|
||||
testCase1.getId(), Entity.TEST_CASE, "followers", List.of(USER1.getId().toString()));
|
||||
|
||||
// TestCase2 should inherit USER2 from table2
|
||||
waitForFieldInSearchIndex(
|
||||
testCase2.getId(), Entity.TEST_CASE, "followers", List.of(USER2.getId().toString()));
|
||||
|
||||
// Test filtering by USER1
|
||||
Map<String, String> queryParams = new HashMap<>();
|
||||
queryParams.put("followedBy", USER1.getName());
|
||||
ResultList<TestCase> results = listTestCasesFromSearch(queryParams, 10, 0, ADMIN_AUTH_HEADERS);
|
||||
|
||||
// Should find test cases from table1 (followed by USER1)
|
||||
assertTrue(
|
||||
results.getData().stream()
|
||||
.anyMatch(tc -> tc.getFullyQualifiedName().equals(testCase1.getFullyQualifiedName())),
|
||||
"TestCase1 should be found when filtering by USER1");
|
||||
assertFalse(
|
||||
results.getData().stream()
|
||||
.anyMatch(tc -> tc.getFullyQualifiedName().equals(testCase2.getFullyQualifiedName())),
|
||||
"TestCase2 should not be found when filtering by USER1");
|
||||
|
||||
// Test filtering by USER2
|
||||
queryParams.put("followedBy", USER2.getName());
|
||||
results = listTestCasesFromSearch(queryParams, 10, 0, ADMIN_AUTH_HEADERS);
|
||||
|
||||
// Should find test cases from table2 (followed by USER2)
|
||||
assertFalse(
|
||||
results.getData().stream()
|
||||
.anyMatch(tc -> tc.getFullyQualifiedName().equals(testCase1.getFullyQualifiedName())),
|
||||
"TestCase1 should not be found when filtering by USER2");
|
||||
assertTrue(
|
||||
results.getData().stream()
|
||||
.anyMatch(tc -> tc.getFullyQualifiedName().equals(testCase2.getFullyQualifiedName())),
|
||||
"TestCase2 should be found when filtering by USER2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_followerInheritanceAfterTableUpdate(TestInfo testInfo)
|
||||
throws IOException, InterruptedException {
|
||||
// Create a table without followers
|
||||
TableResourceTest tableResourceTest = new TableResourceTest();
|
||||
CreateTable tableReq =
|
||||
tableResourceTest
|
||||
.createRequest(testInfo)
|
||||
.withColumns(
|
||||
List.of(new Column().withName(C1).withDisplayName("c1").withDataType(BIGINT)));
|
||||
Table table = tableResourceTest.createAndCheckEntity(tableReq, ADMIN_AUTH_HEADERS);
|
||||
|
||||
// Create a test case for this table
|
||||
CreateTestCase create =
|
||||
createRequest(testInfo)
|
||||
.withEntityLink(String.format("<#E::table::%s>", table.getFullyQualifiedName()))
|
||||
.withTestDefinition(TEST_DEFINITION4.getFullyQualifiedName())
|
||||
.withParameterValues(
|
||||
List.of(new TestCaseParameterValue().withValue("10").withName("minValue")));
|
||||
TestCase testCase = createAndCheckEntity(create, ADMIN_AUTH_HEADERS);
|
||||
|
||||
// Wait for initial sync
|
||||
waitForSyncAndGetFromSearchIndex(testCase.getUpdatedAt(), testCase.getId(), Entity.TEST_CASE);
|
||||
|
||||
// Now add USER1 as follower to the table
|
||||
tableResourceTest.addFollower(table.getId(), USER1_REF.getId(), OK, ADMIN_AUTH_HEADERS);
|
||||
|
||||
// Wait for the follower to propagate to the test case in the search index
|
||||
List<String> expectedFollowers = List.of(USER1.getId().toString());
|
||||
waitForFieldInSearchIndex(testCase.getId(), Entity.TEST_CASE, "followers", expectedFollowers);
|
||||
|
||||
// Verify test cases now show up when filtering by USER1
|
||||
Map<String, String> queryParams = new HashMap<>();
|
||||
queryParams.put("followedBy", USER1.getName());
|
||||
ResultList<TestCase> results = listTestCasesFromSearch(queryParams, 10, 0, ADMIN_AUTH_HEADERS);
|
||||
|
||||
assertTrue(
|
||||
results.getData().stream()
|
||||
.anyMatch(tc -> tc.getFullyQualifiedName().equals(testCase.getFullyQualifiedName())),
|
||||
"Test case should be found after table follower update");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_entityStatusUpdateAndPatch(TestInfo test) throws IOException {
|
||||
// Create a test case with APPROVED status by default
|
||||
|
@ -139,6 +139,10 @@
|
||||
"description": "Domains the test case belongs to. When not set, the test case inherits the domain from the table it belongs to.",
|
||||
"$ref": "../type/entityReferenceList.json"
|
||||
},
|
||||
"followers": {
|
||||
"description": "Followers of this test case. When not set, the test case inherits the followers from the table it belongs to.",
|
||||
"$ref": "../type/entityReferenceList.json"
|
||||
},
|
||||
"useDynamicAssertion": {
|
||||
"description": "If the test definition supports it, use dynamic assertion to evaluate the test case.",
|
||||
"type": "boolean",
|
||||
|
@ -57,6 +57,11 @@ export interface TestCase {
|
||||
* Sample of failed rows for this test case.
|
||||
*/
|
||||
failedRowsSample?: TableData;
|
||||
/**
|
||||
* Followers of this test case. When not set, the test case inherits the followers from the
|
||||
* table it belongs to.
|
||||
*/
|
||||
followers?: EntityReference[];
|
||||
/**
|
||||
* FullyQualifiedName same as `name`.
|
||||
*/
|
||||
|
Loading…
x
Reference in New Issue
Block a user