feat(terms) Add ability to Add and Remove Related Terms to Glossary Terms (#5120)

This commit is contained in:
Chris Collins 2022-06-08 10:50:29 -04:00 committed by GitHub
parent c677a06fd8
commit 58dc909a80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1076 additions and 64 deletions

View File

@ -86,12 +86,14 @@ import com.linkedin.datahub.graphql.resolvers.domain.ListDomainsResolver;
import com.linkedin.datahub.graphql.resolvers.domain.SetDomainResolver;
import com.linkedin.datahub.graphql.resolvers.domain.UnsetDomainResolver;
import com.linkedin.datahub.graphql.resolvers.entity.EntityExistsResolver;
import com.linkedin.datahub.graphql.resolvers.glossary.AddRelatedTermsResolver;
import com.linkedin.datahub.graphql.resolvers.glossary.CreateGlossaryNodeResolver;
import com.linkedin.datahub.graphql.resolvers.glossary.CreateGlossaryTermResolver;
import com.linkedin.datahub.graphql.resolvers.glossary.DeleteGlossaryEntityResolver;
import com.linkedin.datahub.graphql.resolvers.glossary.GetRootGlossaryNodesResolver;
import com.linkedin.datahub.graphql.resolvers.glossary.GetRootGlossaryTermsResolver;
import com.linkedin.datahub.graphql.resolvers.glossary.ParentNodesResolver;
import com.linkedin.datahub.graphql.resolvers.glossary.RemoveRelatedTermsResolver;
import com.linkedin.datahub.graphql.resolvers.group.AddGroupMembersResolver;
import com.linkedin.datahub.graphql.resolvers.group.CreateGroupResolver;
import com.linkedin.datahub.graphql.resolvers.group.EntityCountsResolver;
@ -695,6 +697,8 @@ public class GmsGraphQLEngine {
.dataFetcher("updateParentNode", new UpdateParentNodeResolver(entityService))
.dataFetcher("deleteGlossaryEntity", new DeleteGlossaryEntityResolver(this.entityClient, this.entityService))
.dataFetcher("updateName", new UpdateNameResolver(entityService))
.dataFetcher("addRelatedTerms", new AddRelatedTermsResolver(this.entityService))
.dataFetcher("removeRelatedTerms", new RemoveRelatedTermsResolver(this.entityService))
);
}

View File

@ -0,0 +1,122 @@
package com.linkedin.datahub.graphql.resolvers.glossary;
import com.linkedin.common.GlossaryTermUrnArray;
import com.linkedin.common.urn.GlossaryTermUrn;
import com.linkedin.common.urn.Urn;
import com.linkedin.common.urn.UrnUtils;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.authorization.AuthorizationUtils;
import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.RelatedTermsInput;
import com.linkedin.datahub.graphql.generated.TermRelationshipType;
import com.linkedin.metadata.Constants;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.glossary.GlossaryRelatedTerms;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument;
import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*;
@Slf4j
@RequiredArgsConstructor
public class AddRelatedTermsResolver implements DataFetcher<CompletableFuture<Boolean>> {
private final EntityService _entityService;
@Override
public CompletableFuture<Boolean> get(DataFetchingEnvironment environment) throws Exception {
final QueryContext context = environment.getContext();
final RelatedTermsInput input = bindArgument(environment.getArgument("input"), RelatedTermsInput.class);
return CompletableFuture.supplyAsync(() -> {
if (AuthorizationUtils.canManageGlossaries(context)) {
try {
final TermRelationshipType relationshipType = input.getRelationshipType();
final Urn urn = Urn.createFromString(input.getUrn());
final List<Urn> termUrns = input.getTermUrns().stream()
.map(UrnUtils::getUrn)
.collect(Collectors.toList());
validateRelatedTermsInput(urn, termUrns);
Urn actor = Urn.createFromString(((QueryContext) context).getActorUrn());
GlossaryRelatedTerms glossaryRelatedTerms = (GlossaryRelatedTerms) getAspectFromEntity(
urn.toString(),
Constants.GLOSSARY_RELATED_TERM_ASPECT_NAME,
_entityService,
null
);
if (glossaryRelatedTerms == null) {
glossaryRelatedTerms = new GlossaryRelatedTerms();
}
if (relationshipType == TermRelationshipType.isA) {
if (!glossaryRelatedTerms.hasIsRelatedTerms()) {
glossaryRelatedTerms.setIsRelatedTerms(new GlossaryTermUrnArray());
}
final GlossaryTermUrnArray existingTermUrns = glossaryRelatedTerms.getIsRelatedTerms();
return updateRelatedTerms(termUrns, existingTermUrns, urn, glossaryRelatedTerms, actor);
} else {
if (!glossaryRelatedTerms.hasHasRelatedTerms()) {
glossaryRelatedTerms.setHasRelatedTerms(new GlossaryTermUrnArray());
}
final GlossaryTermUrnArray existingTermUrns = glossaryRelatedTerms.getHasRelatedTerms();
return updateRelatedTerms(termUrns, existingTermUrns, urn, glossaryRelatedTerms, actor);
}
} catch (Exception e) {
throw new RuntimeException(String.format("Failed to add related terms to %s", input.getUrn()), e);
}
}
throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator.");
});
}
public Boolean validateRelatedTermsInput(Urn urn, List<Urn> termUrns) {
if (!urn.getEntityType().equals(Constants.GLOSSARY_TERM_ENTITY_NAME) || !_entityService.exists(urn)) {
throw new IllegalArgumentException(String.format("Failed to update %s. %s either does not exist or is not a glossaryTerm.", urn, urn));
}
for (Urn termUrn : termUrns) {
if (termUrn.equals(urn)) {
throw new IllegalArgumentException(String.format("Failed to update %s. Tried to create related term with itself.", urn));
} else if (!termUrn.getEntityType().equals(Constants.GLOSSARY_TERM_ENTITY_NAME)) {
throw new IllegalArgumentException(String.format("Failed to update %s. %s is not a glossaryTerm.", urn, termUrn));
} else if (!_entityService.exists(termUrn)) {
throw new IllegalArgumentException(String.format("Failed to update %s. %s does not exist.", urn, termUrn));
}
}
return true;
}
private Boolean updateRelatedTerms(List<Urn> termUrns, GlossaryTermUrnArray existingTermUrns, Urn urn, GlossaryRelatedTerms glossaryRelatedTerms, Urn actor) {
List<Urn> termsToAdd = new ArrayList<>();
for (Urn termUrn : termUrns) {
if (existingTermUrns.stream().anyMatch(association -> association.equals(termUrn))) {
continue;
}
termsToAdd.add(termUrn);
}
if (termsToAdd.size() == 0) {
return true;
}
for (Urn termUrn : termsToAdd) {
GlossaryTermUrn newUrn = new GlossaryTermUrn(termUrn.getId());
existingTermUrns.add(newUrn);
}
persistAspect(urn, Constants.GLOSSARY_RELATED_TERM_ASPECT_NAME, glossaryRelatedTerms, actor, _entityService);
return true;
}
}

View File

@ -0,0 +1,90 @@
package com.linkedin.datahub.graphql.resolvers.glossary;
import com.linkedin.common.GlossaryTermUrnArray;
import com.linkedin.common.urn.Urn;
import com.linkedin.common.urn.UrnUtils;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.authorization.AuthorizationUtils;
import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.RelatedTermsInput;
import com.linkedin.datahub.graphql.generated.TermRelationshipType;
import com.linkedin.glossary.GlossaryRelatedTerms;
import com.linkedin.metadata.Constants;
import com.linkedin.metadata.entity.EntityService;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument;
import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.getAspectFromEntity;
import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.persistAspect;
@Slf4j
@RequiredArgsConstructor
public class RemoveRelatedTermsResolver implements DataFetcher<CompletableFuture<Boolean>> {
private final EntityService _entityService;
@Override
public CompletableFuture<Boolean> get(DataFetchingEnvironment environment) throws Exception {
final QueryContext context = environment.getContext();
final RelatedTermsInput input = bindArgument(environment.getArgument("input"), RelatedTermsInput.class);
return CompletableFuture.supplyAsync(() -> {
if (AuthorizationUtils.canManageGlossaries(context)) {
try {
final TermRelationshipType relationshipType = input.getRelationshipType();
final Urn urn = Urn.createFromString(input.getUrn());
final List<Urn> termUrnsToRemove = input.getTermUrns().stream()
.map(UrnUtils::getUrn)
.collect(Collectors.toList());
if (!urn.getEntityType().equals(Constants.GLOSSARY_TERM_ENTITY_NAME) || !_entityService.exists(urn)) {
throw new IllegalArgumentException(String.format("Failed to update %s. %s either does not exist or is not a glossaryTerm.", urn, urn));
}
Urn actor = Urn.createFromString(((QueryContext) context).getActorUrn());
GlossaryRelatedTerms glossaryRelatedTerms = (GlossaryRelatedTerms) getAspectFromEntity(
urn.toString(),
Constants.GLOSSARY_RELATED_TERM_ASPECT_NAME,
_entityService,
null
);
if (glossaryRelatedTerms == null) {
throw new RuntimeException(String.format("Related Terms for this Urn do not exist: %s", urn));
}
if (relationshipType == TermRelationshipType.isA) {
if (!glossaryRelatedTerms.hasIsRelatedTerms()) {
throw new RuntimeException("Failed to remove from GlossaryRelatedTerms as they do not exist for this Glossary Term");
}
final GlossaryTermUrnArray existingTermUrns = glossaryRelatedTerms.getIsRelatedTerms();
existingTermUrns.removeIf(termUrn -> termUrnsToRemove.stream().anyMatch(termUrn::equals));
persistAspect(urn, Constants.GLOSSARY_RELATED_TERM_ASPECT_NAME, glossaryRelatedTerms, actor, _entityService);
return true;
} else {
if (!glossaryRelatedTerms.hasHasRelatedTerms()) {
throw new RuntimeException("Failed to remove from GlossaryRelatedTerms as they do not exist for this Glossary Term");
}
final GlossaryTermUrnArray existingTermUrns = glossaryRelatedTerms.getHasRelatedTerms();
existingTermUrns.removeIf(termUrn -> termUrnsToRemove.stream().anyMatch(termUrn::equals));
persistAspect(urn, Constants.GLOSSARY_RELATED_TERM_ASPECT_NAME, glossaryRelatedTerms, actor, _entityService);
return true;
}
} catch (Exception e) {
throw new RuntimeException(String.format("Failed to removes related terms from %s", input.getUrn()), e);
}
}
throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator.");
});
}
}

View File

@ -390,6 +390,16 @@ type Mutation {
Updates the name of the entity.
"""
updateName(input: UpdateNameInput!): Boolean
"""
Add multiple related Terms to a Glossary Term to establish relationships
"""
addRelatedTerms(input: RelatedTermsInput!): Boolean
"""
Remove multiple related Terms for a Glossary Term
"""
removeRelatedTerms(input: RelatedTermsInput!): Boolean
}
"""
@ -6083,6 +6093,42 @@ enum SubResourceType {
DATASET_FIELD
}
"""
Input provided when adding Terms to an asset
"""
input RelatedTermsInput {
"""
The Glossary Term urn to add or remove this relationship to/from
"""
urn: String!
"""
The primary key of the Glossary Term to add or remove
"""
termUrns: [String!]!
"""
The type of relationship we're adding or removing to/from for a Glossary Term
"""
relationshipType: TermRelationshipType!
}
"""
A type of Metadata Entity sub resource
"""
enum TermRelationshipType {
"""
When a Term inherits from, or has an 'Is A' relationship with another Term
"""
isA
"""
When a Term contains, or has a 'Has A' relationship with another Term
"""
hasA
}
"""
Input provided when updating the association between a Metadata Entity and a Tag
"""

View File

@ -0,0 +1,255 @@
package com.linkedin.datahub.graphql.resolvers.glossary;
import com.google.common.collect.ImmutableList;
import com.linkedin.common.AuditStamp;
import com.linkedin.common.urn.Urn;
import com.linkedin.common.urn.UrnUtils;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.RelatedTermsInput;
import com.linkedin.datahub.graphql.generated.TermRelationshipType;
import com.linkedin.metadata.Constants;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.mxe.MetadataChangeProposal;
import graphql.schema.DataFetchingEnvironment;
import java.util.concurrent.ExecutionException;
import org.mockito.Mockito;
import org.testng.annotations.Test;
import static com.linkedin.datahub.graphql.TestUtils.*;
import static com.linkedin.datahub.graphql.TestUtils.getMockDenyContext;
import static org.testng.Assert.*;
public class AddRelatedTermsResolverTest {
private static final String TEST_ENTITY_URN = "urn:li:glossaryTerm:test-id-0";
private static final String TEST_TERM_1_URN = "urn:li:glossaryTerm:test-id-1";
private static final String TEST_TERM_2_URN = "urn:li:glossaryTerm:test-id-2";
private static final String DATASET_URN = "urn:li:dataset:(test,test,test)";
private EntityService setUpService() {
EntityService mockService = Mockito.mock(EntityService.class);
Mockito.when(mockService.getAspect(
Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)),
Mockito.eq(Constants.GLOSSARY_RELATED_TERM_ASPECT_NAME),
Mockito.eq(0L)))
.thenReturn(null);
return mockService;
}
@Test
public void testGetSuccessIsRelatedNonExistent() throws Exception {
EntityService mockService = setUpService();
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true);
Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true);
Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_2_URN))).thenReturn(true);
AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService);
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
RelatedTermsInput input = new RelatedTermsInput(TEST_ENTITY_URN, ImmutableList.of(
TEST_TERM_1_URN,
TEST_TERM_2_URN
), TermRelationshipType.isA);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertTrue(resolver.get(mockEnv).get());
Mockito.verify(mockService, Mockito.times(1)).ingestProposal(
Mockito.any(MetadataChangeProposal.class),
Mockito.any(AuditStamp.class)
);
Mockito.verify(mockService, Mockito.times(1)).exists(
Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))
);
Mockito.verify(mockService, Mockito.times(1)).exists(
Mockito.eq(Urn.createFromString(TEST_TERM_1_URN))
);
Mockito.verify(mockService, Mockito.times(1)).exists(
Mockito.eq(Urn.createFromString(TEST_TERM_2_URN))
);
}
@Test
public void testGetSuccessHasRelatedNonExistent() throws Exception {
EntityService mockService = setUpService();
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true);
Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true);
Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_2_URN))).thenReturn(true);
AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService);
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
RelatedTermsInput input = new RelatedTermsInput(TEST_ENTITY_URN, ImmutableList.of(
TEST_TERM_1_URN,
TEST_TERM_2_URN
), TermRelationshipType.hasA);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertTrue(resolver.get(mockEnv).get());
Mockito.verify(mockService, Mockito.times(1)).ingestProposal(
Mockito.any(MetadataChangeProposal.class),
Mockito.any(AuditStamp.class)
);
Mockito.verify(mockService, Mockito.times(1)).exists(
Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))
);
Mockito.verify(mockService, Mockito.times(1)).exists(
Mockito.eq(Urn.createFromString(TEST_TERM_1_URN))
);
Mockito.verify(mockService, Mockito.times(1)).exists(
Mockito.eq(Urn.createFromString(TEST_TERM_2_URN))
);
}
@Test
public void testGetFailAddSelfAsRelatedTerm() throws Exception {
EntityService mockService = setUpService();
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true);
AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService);
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
RelatedTermsInput input = new RelatedTermsInput(TEST_ENTITY_URN, ImmutableList.of(
TEST_ENTITY_URN
), TermRelationshipType.hasA);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get());
Mockito.verify(mockService, Mockito.times(0)).ingestProposal(
Mockito.any(MetadataChangeProposal.class),
Mockito.any(AuditStamp.class)
);
}
@Test
public void testGetFailAddNonTermAsRelatedTerm() throws Exception {
EntityService mockService = setUpService();
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true);
AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService);
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
RelatedTermsInput input = new RelatedTermsInput(TEST_ENTITY_URN, ImmutableList.of(
DATASET_URN
), TermRelationshipType.hasA);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get());
Mockito.verify(mockService, Mockito.times(0)).ingestProposal(
Mockito.any(MetadataChangeProposal.class),
Mockito.any(AuditStamp.class)
);
}
@Test
public void testGetFailAddNonExistentTermAsRelatedTerm() throws Exception {
EntityService mockService = setUpService();
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true);
Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(false);
AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService);
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
RelatedTermsInput input = new RelatedTermsInput(TEST_ENTITY_URN, ImmutableList.of(
TEST_TERM_1_URN
), TermRelationshipType.hasA);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get());
Mockito.verify(mockService, Mockito.times(0)).ingestProposal(
Mockito.any(MetadataChangeProposal.class),
Mockito.any(AuditStamp.class)
);
}
@Test
public void testGetFailAddToNonExistentUrn() throws Exception {
EntityService mockService = setUpService();
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false);
Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true);
AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService);
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
RelatedTermsInput input = new RelatedTermsInput(TEST_ENTITY_URN, ImmutableList.of(
TEST_TERM_1_URN
), TermRelationshipType.hasA);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get());
Mockito.verify(mockService, Mockito.times(0)).ingestProposal(
Mockito.any(MetadataChangeProposal.class),
Mockito.any(AuditStamp.class)
);
}
@Test
public void testGetFailAddToNonTerm() throws Exception {
EntityService mockService = setUpService();
Mockito.when(mockService.exists(Urn.createFromString(DATASET_URN))).thenReturn(true);
Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true);
AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService);
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
RelatedTermsInput input = new RelatedTermsInput(DATASET_URN, ImmutableList.of(
TEST_TERM_1_URN
), TermRelationshipType.hasA);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get());
Mockito.verify(mockService, Mockito.times(0)).ingestProposal(
Mockito.any(MetadataChangeProposal.class),
Mockito.any(AuditStamp.class)
);
}
@Test
public void testFailNoPermissions() throws Exception {
EntityService mockService = setUpService();
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true);
Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_1_URN))).thenReturn(true);
Mockito.when(mockService.exists(Urn.createFromString(TEST_TERM_2_URN))).thenReturn(true);
AddRelatedTermsResolver resolver = new AddRelatedTermsResolver(mockService);
QueryContext mockContext = getMockDenyContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
RelatedTermsInput input = new RelatedTermsInput(TEST_ENTITY_URN, ImmutableList.of(
TEST_TERM_1_URN,
TEST_TERM_2_URN
), TermRelationshipType.isA);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get());
Mockito.verify(mockService, Mockito.times(0)).ingestProposal(
Mockito.any(MetadataChangeProposal.class),
Mockito.any(AuditStamp.class)
);
}
}

View File

@ -0,0 +1,166 @@
package com.linkedin.datahub.graphql.resolvers.glossary;
import com.google.common.collect.ImmutableList;
import com.linkedin.common.AuditStamp;
import com.linkedin.common.GlossaryTermUrnArray;
import com.linkedin.common.urn.GlossaryTermUrn;
import com.linkedin.common.urn.Urn;
import com.linkedin.common.urn.UrnUtils;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.RelatedTermsInput;
import com.linkedin.datahub.graphql.generated.TermRelationshipType;
import com.linkedin.glossary.GlossaryRelatedTerms;
import com.linkedin.metadata.Constants;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.mxe.MetadataChangeProposal;
import graphql.schema.DataFetchingEnvironment;
import org.mockito.Mockito;
import org.testng.annotations.Test;
import java.util.Arrays;
import java.util.concurrent.ExecutionException;
import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext;
import static com.linkedin.datahub.graphql.TestUtils.getMockDenyContext;
import static org.testng.Assert.assertThrows;
import static org.testng.Assert.assertTrue;
public class RemoveRelatedTermsResolverTest {
private static final String TEST_ENTITY_URN = "urn:li:glossaryTerm:test-id-0";
private static final String TEST_TERM_1_URN = "urn:li:glossaryTerm:test-id-1";
private static final String TEST_TERM_2_URN = "urn:li:glossaryTerm:test-id-2";
@Test
public void testGetSuccessIsA() throws Exception {
GlossaryTermUrn term1Urn = GlossaryTermUrn.createFromString(TEST_TERM_1_URN);
GlossaryTermUrn term2Urn = GlossaryTermUrn.createFromString(TEST_TERM_2_URN);
final GlossaryRelatedTerms relatedTerms = new GlossaryRelatedTerms();
relatedTerms.setIsRelatedTerms(new GlossaryTermUrnArray(Arrays.asList(term1Urn, term2Urn)));
EntityService mockService = Mockito.mock(EntityService.class);
Mockito.when(mockService.getAspect(
Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)),
Mockito.eq(Constants.GLOSSARY_RELATED_TERM_ASPECT_NAME),
Mockito.eq(0L)))
.thenReturn(relatedTerms);
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true);
RemoveRelatedTermsResolver resolver = new RemoveRelatedTermsResolver(mockService);
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
RelatedTermsInput input = new RelatedTermsInput(TEST_ENTITY_URN, ImmutableList.of(
TEST_TERM_1_URN
), TermRelationshipType.isA);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertTrue(resolver.get(mockEnv).get());
Mockito.verify(mockService, Mockito.times(1)).ingestProposal(
Mockito.any(MetadataChangeProposal.class),
Mockito.any(AuditStamp.class)
);
Mockito.verify(mockService, Mockito.times(1)).exists(
Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))
);
}
@Test
public void testGetSuccessHasA() throws Exception {
GlossaryTermUrn term1Urn = GlossaryTermUrn.createFromString(TEST_TERM_1_URN);
GlossaryTermUrn term2Urn = GlossaryTermUrn.createFromString(TEST_TERM_2_URN);
final GlossaryRelatedTerms relatedTerms = new GlossaryRelatedTerms();
relatedTerms.setHasRelatedTerms(new GlossaryTermUrnArray(Arrays.asList(term1Urn, term2Urn)));
EntityService mockService = Mockito.mock(EntityService.class);
Mockito.when(mockService.getAspect(
Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)),
Mockito.eq(Constants.GLOSSARY_RELATED_TERM_ASPECT_NAME),
Mockito.eq(0L)))
.thenReturn(relatedTerms);
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true);
RemoveRelatedTermsResolver resolver = new RemoveRelatedTermsResolver(mockService);
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
RelatedTermsInput input = new RelatedTermsInput(TEST_ENTITY_URN, ImmutableList.of(
TEST_TERM_1_URN
), TermRelationshipType.hasA);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertTrue(resolver.get(mockEnv).get());
Mockito.verify(mockService, Mockito.times(1)).ingestProposal(
Mockito.any(MetadataChangeProposal.class),
Mockito.any(AuditStamp.class)
);
Mockito.verify(mockService, Mockito.times(1)).exists(
Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))
);
}
@Test
public void testFailAspectDoesNotExist() throws Exception {
EntityService mockService = Mockito.mock(EntityService.class);
Mockito.when(mockService.getAspect(
Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)),
Mockito.eq(Constants.GLOSSARY_RELATED_TERM_ASPECT_NAME),
Mockito.eq(0L)))
.thenReturn(null);
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true);
RemoveRelatedTermsResolver resolver = new RemoveRelatedTermsResolver(mockService);
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
RelatedTermsInput input = new RelatedTermsInput(TEST_ENTITY_URN, ImmutableList.of(
TEST_TERM_1_URN
), TermRelationshipType.hasA);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get());
Mockito.verify(mockService, Mockito.times(0)).ingestProposal(
Mockito.any(MetadataChangeProposal.class),
Mockito.any(AuditStamp.class)
);
}
@Test
public void testFailNoPermissions() throws Exception {
GlossaryTermUrn term1Urn = GlossaryTermUrn.createFromString(TEST_TERM_1_URN);
GlossaryTermUrn term2Urn = GlossaryTermUrn.createFromString(TEST_TERM_2_URN);
final GlossaryRelatedTerms relatedTerms = new GlossaryRelatedTerms();
relatedTerms.setIsRelatedTerms(new GlossaryTermUrnArray(Arrays.asList(term1Urn, term2Urn)));
EntityService mockService = Mockito.mock(EntityService.class);
Mockito.when(mockService.getAspect(
Mockito.eq(UrnUtils.getUrn(TEST_ENTITY_URN)),
Mockito.eq(Constants.GLOSSARY_RELATED_TERM_ASPECT_NAME),
Mockito.eq(0L)))
.thenReturn(relatedTerms);
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true);
RemoveRelatedTermsResolver resolver = new RemoveRelatedTermsResolver(mockService);
QueryContext mockContext = getMockDenyContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
RelatedTermsInput input = new RelatedTermsInput(TEST_ENTITY_URN, ImmutableList.of(
TEST_TERM_1_URN
), TermRelationshipType.isA);
Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(ExecutionException.class, () -> resolver.get(mockEnv).get());
Mockito.verify(mockService, Mockito.times(0)).ingestProposal(
Mockito.any(MetadataChangeProposal.class),
Mockito.any(AuditStamp.class)
);
Mockito.verify(mockService, Mockito.times(0)).exists(
Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))
);
}
}

View File

@ -0,0 +1,205 @@
import { message, Button, Modal, Select, Tag } from 'antd';
import React, { useState } from 'react';
import styled from 'styled-components/macro';
import { useAddRelatedTermsMutation } from '../../../../graphql/glossaryTerm.generated';
import { useGetSearchResultsLazyQuery } from '../../../../graphql/search.generated';
import { EntityType, SearchResult, TermRelationshipType } from '../../../../types.generated';
import GlossaryBrowser from '../../../glossary/GlossaryBrowser/GlossaryBrowser';
import ClickOutside from '../../../shared/ClickOutside';
import { BrowserWrapper } from '../../../shared/tags/AddTagsTermsModal';
import TermLabel from '../../../shared/TermLabel';
import { useEntityRegistry } from '../../../useEntityRegistry';
import { useEntityData, useRefetch } from '../../shared/EntityContext';
const StyledSelect = styled(Select)`
width: 480px;
`;
interface Props {
onClose: () => void;
relationshipType: TermRelationshipType;
}
function AddRelatedTermsModal(props: Props) {
const { onClose, relationshipType } = props;
const [inputValue, setInputValue] = useState('');
const [selectedUrns, setSelectedUrns] = useState<any[]>([]);
const [selectedTerms, setSelectedTerms] = useState<any[]>([]);
const [isFocusedOnInput, setIsFocusedOnInput] = useState(false);
const entityRegistry = useEntityRegistry();
const { urn: entityDataUrn } = useEntityData();
const refetch = useRefetch();
const [AddRelatedTerms] = useAddRelatedTermsMutation();
function addTerms() {
AddRelatedTerms({
variables: {
input: {
urn: entityDataUrn,
termUrns: selectedUrns,
relationshipType,
},
},
})
.catch((e) => {
message.destroy();
message.error({ content: `Failed to move: \n ${e.message || ''}`, duration: 3 });
})
.finally(() => {
message.loading({ content: 'Adding...', duration: 2 });
setTimeout(() => {
message.success({
content: 'Added Related Terms!',
duration: 2,
});
refetch();
}, 2000);
});
onClose();
}
const [termSearch, { data: termSearchData }] = useGetSearchResultsLazyQuery();
const termSearchResults = termSearchData?.search?.searchResults || [];
const tagSearchOptions = termSearchResults.map((result: SearchResult) => {
const displayName = entityRegistry.getDisplayName(result.entity.type, result.entity);
return (
<Select.Option value={result.entity.urn} key={result.entity.urn} name={displayName}>
<TermLabel name={displayName} />
</Select.Option>
);
});
const handleSearch = (text: string) => {
if (text.length > 0) {
termSearch({
variables: {
input: {
type: EntityType.GlossaryTerm,
query: text,
start: 0,
count: 20,
},
},
});
}
};
// When a Tag or term search result is selected, add the urn to the Urns
const onSelectValue = (urn: string) => {
const newUrns = [...selectedUrns, urn];
setSelectedUrns(newUrns);
const selectedSearchOption = tagSearchOptions.find((option) => option.props.value === urn);
setSelectedTerms([...selectedTerms, { urn, component: <TermLabel name={selectedSearchOption?.props.name} /> }]);
};
// When a Tag or term search result is deselected, remove the urn from the Owners
const onDeselectValue = (urn: string) => {
const newUrns = selectedUrns.filter((u) => u !== urn);
setSelectedUrns(newUrns);
setInputValue('');
setIsFocusedOnInput(true);
setSelectedTerms(selectedTerms.filter((term) => term.urn !== urn));
};
function selectTermFromBrowser(urn: string, displayName: string) {
setIsFocusedOnInput(false);
const newUrns = [...selectedUrns, urn];
setSelectedUrns(newUrns);
setSelectedTerms([...selectedTerms, { urn, component: <TermLabel name={displayName} /> }]);
}
function clearInput() {
setInputValue('');
setTimeout(() => setIsFocusedOnInput(true), 0); // call after click outside
}
function handleBlur() {
setInputValue('');
}
const tagRender = (properties) => {
// eslint-disable-next-line react/prop-types
const { closable, onClose: close, value } = properties;
const onPreventMouseDown = (event) => {
event.preventDefault();
event.stopPropagation();
};
const selectedItem = selectedTerms.find((term) => term.urn === value).component;
return (
<Tag
onMouseDown={onPreventMouseDown}
closable={closable}
onClose={close}
style={{
marginRight: 3,
display: 'flex',
justifyContent: 'start',
alignItems: 'center',
whiteSpace: 'nowrap',
opacity: 1,
color: '#434343',
lineHeight: '16px',
}}
>
{selectedItem}
</Tag>
);
};
const isShowingGlossaryBrowser = !inputValue && isFocusedOnInput;
return (
<Modal
title="Add Related Terms"
visible
onCancel={onClose}
footer={
<>
<Button onClick={onClose} type="text">
Cancel
</Button>
<Button onClick={addTerms} disabled={!selectedUrns.length}>
Add
</Button>
</>
}
>
<ClickOutside onClickOutside={() => setIsFocusedOnInput(false)}>
<StyledSelect
autoFocus
mode="multiple"
filterOption={false}
placeholder="Search for Glossary Terms..."
showSearch
defaultActiveFirstOption={false}
onSelect={(asset: any) => onSelectValue(asset)}
onDeselect={(asset: any) => onDeselectValue(asset)}
onSearch={(value: string) => {
// eslint-disable-next-line react/prop-types
handleSearch(value.trim());
// eslint-disable-next-line react/prop-types
setInputValue(value.trim());
}}
tagRender={tagRender}
value={selectedUrns}
onClear={clearInput}
onFocus={() => setIsFocusedOnInput(true)}
onBlur={handleBlur}
dropdownStyle={isShowingGlossaryBrowser || !inputValue ? { display: 'none' } : {}}
>
{tagSearchOptions}
</StyledSelect>
<BrowserWrapper isHidden={!isShowingGlossaryBrowser}>
<GlossaryBrowser isSelecting selectTerm={selectTermFromBrowser} />
</BrowserWrapper>
</ClickOutside>
</Modal>
);
}
export default AddRelatedTermsModal;

View File

@ -1,26 +1,23 @@
import { Menu } from 'antd';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import styled from 'styled-components/macro';
import { useEntityData } from '../../shared/EntityContext';
import GlossaryRelatedTermsResult from './GlossaryRelatedTermsResult';
export enum RelatedTermTypes {
hasRelatedTerms = 'Contains',
isRelatedTerms = 'Inherits',
}
import GlossaryRelatedTermsResult, { RelatedTermTypes } from './GlossaryRelatedTermsResult';
const DetailWrapper = styled.div`
display: inline-flex;
flex: 1;
width: 100%;
`;
const MenuWrapper = styled.div`
border: 2px solid #f5f5f5;
border-right: 2px solid #f5f5f5;
`;
const Content = styled.div`
margin-left: 32px;
flex-grow: 1;
max-width: 100%;
overflow: hidden;
`;
export default function GlossayRelatedTerms() {

View File

@ -1,12 +1,17 @@
import { QueryResult } from '@apollo/client';
import { Divider, List, Typography } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { GetGlossaryTermQuery, useGetGlossaryTermQuery } from '../../../../graphql/glossaryTerm.generated';
import { EntityType, Exact } from '../../../../types.generated';
import { PlusOutlined } from '@ant-design/icons';
import { Button, Typography } from 'antd';
import React, { useState } from 'react';
import styled from 'styled-components/macro';
import { TermRelationshipType } from '../../../../types.generated';
import { Message } from '../../../shared/Message';
import { useEntityRegistry } from '../../../useEntityRegistry';
import { PreviewType } from '../../Entity';
import { ANTD_GRAY } from '../../shared/constants';
import AddRelatedTermsModal from './AddRelatedTermsModal';
import RelatedTerm from './RelatedTerm';
export enum RelatedTermTypes {
hasRelatedTerms = 'Contains',
isRelatedTerms = 'Inherits',
}
export type Props = {
glossaryRelatedTermType: string;
@ -14,47 +19,32 @@ export type Props = {
};
const ListContainer = styled.div`
display: default;
flex-grow: default;
width: 100%;
`;
const TitleContainer = styled.div`
align-items: center;
border-bottom: solid 1px ${ANTD_GRAY[4]};
display: flex;
justify-content: space-between;
padding: 15px 20px;
margin-bottom: 30px;
`;
const ListItem = styled.div`
margin: 40px;
padding-bottom: 5px;
`;
const Profile = styled.div`
marging-bottom: 20px;
`;
const messageStyle = { marginTop: '10%' };
export default function GlossaryRelatedTermsResult({ glossaryRelatedTermType, glossaryRelatedTermResult }: Props) {
const entityRegistry = useEntityRegistry();
const [isShowingAddModal, setIsShowingAddModal] = useState(false);
const glossaryRelatedTermUrns: Array<string> = [];
glossaryRelatedTermResult.forEach((item: any) => {
glossaryRelatedTermUrns.push(item?.entity?.urn);
});
const glossaryTermInfo: QueryResult<GetGlossaryTermQuery, Exact<{ urn: string }>>[] = [];
const contentLoading = false;
const relationshipType =
glossaryRelatedTermType === RelatedTermTypes.hasRelatedTerms
? TermRelationshipType.HasA
: TermRelationshipType.IsA;
for (let i = 0; i < glossaryRelatedTermUrns.length; i++) {
glossaryTermInfo.push(
// eslint-disable-next-line react-hooks/rules-of-hooks
useGetGlossaryTermQuery({
variables: {
urn: glossaryRelatedTermUrns[i],
},
}),
);
}
const contentLoading = glossaryTermInfo.some((item) => {
return item.loading;
});
return (
<>
{contentLoading ? (
@ -62,28 +52,21 @@ export default function GlossaryRelatedTermsResult({ glossaryRelatedTermType, gl
) : (
<ListContainer>
<TitleContainer>
<Typography.Title level={3}>{glossaryRelatedTermType}</Typography.Title>
<Divider />
<Typography.Title style={{ margin: '0' }} level={3}>
{glossaryRelatedTermType}
</Typography.Title>
<Button type="text" onClick={() => setIsShowingAddModal(true)}>
<PlusOutlined /> Add Terms
</Button>
</TitleContainer>
<List
dataSource={glossaryTermInfo}
renderItem={(item) => {
return (
<ListItem>
<Profile>
{entityRegistry.renderPreview(
EntityType.GlossaryTerm,
PreviewType.PREVIEW,
item?.data?.glossaryTerm,
)}
</Profile>
<Divider />
</ListItem>
);
}}
/>
{glossaryRelatedTermUrns.map((urn) => (
<RelatedTerm key={urn} urn={urn} relationshipType={relationshipType} />
))}
</ListContainer>
)}
{isShowingAddModal && (
<AddRelatedTermsModal onClose={() => setIsShowingAddModal(false)} relationshipType={relationshipType} />
)}
</>
);
}

View File

@ -0,0 +1,77 @@
import { DeleteOutlined, MoreOutlined } from '@ant-design/icons';
import { Divider, Dropdown, Menu } from 'antd';
import React from 'react';
import styled from 'styled-components/macro';
import { useGetGlossaryTermQuery } from '../../../../graphql/glossaryTerm.generated';
import { EntityType, TermRelationshipType } from '../../../../types.generated';
import { useEntityRegistry } from '../../../useEntityRegistry';
import { PreviewType } from '../../Entity';
import useRemoveRelatedTerms from './useRemoveRelatedTerms';
const ListItem = styled.div`
margin: 0 20px;
`;
const Profile = styled.div`
display: felx;
marging-bottom: 20px;
`;
const MenuIcon = styled(MoreOutlined)`
display: flex;
justify-content: center;
align-items: center;
font-size: 20px;
height: 32px;
margin-left: -10px;
`;
const MenuItem = styled.div`
font-size: 12px;
padding: 0 4px;
color: #262626;
`;
interface Props {
urn: string;
relationshipType: TermRelationshipType;
}
function RelatedTerm(props: Props) {
const { urn, relationshipType } = props;
const entityRegistry = useEntityRegistry();
const { data, loading } = useGetGlossaryTermQuery({ variables: { urn } });
let displayName = '';
if (data) {
displayName = entityRegistry.getDisplayName(EntityType.GlossaryTerm, data.glossaryTerm);
}
const { onRemove } = useRemoveRelatedTerms(urn, relationshipType, displayName);
if (loading) return null;
return (
<ListItem>
<Profile>
{entityRegistry.renderPreview(EntityType.GlossaryTerm, PreviewType.PREVIEW, data?.glossaryTerm)}
<Dropdown
overlay={
<Menu>
<Menu.Item key="0">
<MenuItem onClick={onRemove}>
<DeleteOutlined /> &nbsp; Remove Term
</MenuItem>
</Menu.Item>
</Menu>
}
trigger={['click']}
>
<MenuIcon />
</Dropdown>
</Profile>
<Divider style={{ margin: '20px 0' }} />
</ListItem>
);
}
export default RelatedTerm;

View File

@ -0,0 +1,60 @@
import { message, Modal } from 'antd';
import { useEntityRegistry } from '../../../useEntityRegistry';
import { useEntityData, useRefetch } from '../../shared/EntityContext';
import { useRemoveRelatedTermsMutation } from '../../../../graphql/glossaryTerm.generated';
import { TermRelationshipType } from '../../../../types.generated';
function useRemoveRelatedTerms(termUrn: string, relationshipType: TermRelationshipType, displayName: string) {
const { urn, entityType } = useEntityData();
const entityRegistry = useEntityRegistry();
const refetch = useRefetch();
const [removeRelatedTerms] = useRemoveRelatedTermsMutation();
function handleRemoveRelatedTerms() {
removeRelatedTerms({
variables: {
input: {
urn,
termUrns: [termUrn],
relationshipType,
},
},
})
.catch((e) => {
message.destroy();
message.error({ content: `Failed to remove: \n ${e.message || ''}`, duration: 3 });
})
.finally(() => {
message.loading({
content: 'Removing...',
duration: 2,
});
setTimeout(() => {
refetch();
message.success({
content: `Removed Glossary Term!`,
duration: 2,
});
}, 2000);
});
}
function onRemove() {
Modal.confirm({
title: `Remove ${displayName}`,
content: `Are you sure you want to remove this ${entityRegistry.getEntityName(entityType)}?`,
onOk() {
handleRemoveRelatedTerms();
},
onCancel() {},
okText: 'Yes',
maskClosable: true,
closable: true,
});
}
return { onRemove };
}
export default useRemoveRelatedTerms;

View File

@ -63,7 +63,6 @@ const ContentContainer = styled.div`
const HeaderAndTabs = styled.div`
flex-grow: 1;
min-width: 640px;
height: 100%;
`;
const HeaderAndTabsFlex = styled.div`

View File

@ -78,3 +78,11 @@ mutation createGlossaryTerm($input: CreateGlossaryEntityInput!) {
mutation createGlossaryNode($input: CreateGlossaryEntityInput!) {
createGlossaryNode(input: $input)
}
mutation addRelatedTerms($input: RelatedTermsInput!) {
addRelatedTerms(input: $input)
}
mutation removeRelatedTerms($input: RelatedTermsInput!) {
removeRelatedTerms(input: $input)
}