feat(Tags/Terms): Backend support for tag & term mutations (#4096)

This commit is contained in:
John Joyce 2022-02-11 07:00:46 -08:00 committed by GitHub
parent 4dba8fe6e7
commit f2f5443d04
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 732 additions and 42 deletions

View File

@ -101,6 +101,7 @@ import com.linkedin.datahub.graphql.resolvers.search.AutoCompleteForMultipleReso
import com.linkedin.datahub.graphql.resolvers.search.AutoCompleteResolver;
import com.linkedin.datahub.graphql.resolvers.search.SearchAcrossEntitiesResolver;
import com.linkedin.datahub.graphql.resolvers.search.SearchResolver;
import com.linkedin.datahub.graphql.resolvers.tag.SetTagColorResolver;
import com.linkedin.datahub.graphql.resolvers.type.AspectInterfaceTypeResolver;
import com.linkedin.datahub.graphql.resolvers.type.EntityInterfaceTypeResolver;
import com.linkedin.datahub.graphql.resolvers.type.HyperParameterValueTypeResolver;
@ -589,6 +590,7 @@ public class GmsGraphQLEngine {
builder.type("Mutation", typeWiring -> typeWiring
.dataFetcher("updateDataset", new AuthenticatedResolver<>(new MutableTypeResolver<>(datasetType)))
.dataFetcher("updateTag", new AuthenticatedResolver<>(new MutableTypeResolver<>(tagType)))
.dataFetcher("setTagColor", new SetTagColorResolver(entityClient, entityService))
.dataFetcher("updateChart", new AuthenticatedResolver<>(new MutableTypeResolver<>(chartType)))
.dataFetcher("updateDashboard", new AuthenticatedResolver<>(new MutableTypeResolver<>(dashboardType)))
.dataFetcher("updateDataJob", new AuthenticatedResolver<>(new MutableTypeResolver<>(dataJobType)))

View File

@ -10,11 +10,13 @@ import com.linkedin.datahub.graphql.authorization.ConjunctivePrivilegeGroup;
import com.linkedin.datahub.graphql.authorization.DisjunctivePrivilegeGroup;
import com.linkedin.datahub.graphql.generated.SubResourceType;
import com.linkedin.domain.DomainProperties;
import com.linkedin.glossary.GlossaryTermInfo;
import com.linkedin.metadata.Constants;
import com.linkedin.metadata.authorization.PoliciesConfig;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.schema.EditableSchemaFieldInfo;
import com.linkedin.schema.EditableSchemaMetadata;
import com.linkedin.tag.TagProperties;
import javax.annotation.Nonnull;
import lombok.extern.slf4j.Slf4j;
@ -69,11 +71,49 @@ public class DescriptionUtils {
) {
DomainProperties domainProperties =
(DomainProperties) getAspectFromEntity(
resourceUrn.toString(), Constants.DOMAIN_PROPERTIES_ASPECT_NAME, entityService, new DomainProperties());
resourceUrn.toString(), Constants.DOMAIN_PROPERTIES_ASPECT_NAME, entityService, null);
if (domainProperties == null) {
// If there are no properties for the domain already, then we should throw since the properties model also requires a name.
throw new IllegalArgumentException("Properties for this Domain do not yet exist!");
}
domainProperties.setDescription(newDescription);
persistAspect(resourceUrn, Constants.DOMAIN_PROPERTIES_ASPECT_NAME, domainProperties, actor, entityService);
}
public static void updateTagDescription(
String newDescription,
Urn resourceUrn,
Urn actor,
EntityService entityService
) {
TagProperties tagProperties =
(TagProperties) getAspectFromEntity(
resourceUrn.toString(), Constants.TAG_PROPERTIES_ASPECT_NAME, entityService, null);
if (tagProperties == null) {
// If there are no properties for the tag already, then we should throw since the properties model also requires a name.
throw new IllegalArgumentException("Properties for this Tag do not yet exist!");
}
tagProperties.setDescription(newDescription);
persistAspect(resourceUrn, Constants.TAG_PROPERTIES_ASPECT_NAME, tagProperties, actor, entityService);
}
public static void updateGlossaryTermDescription(
String newDescription,
Urn resourceUrn,
Urn actor,
EntityService entityService
) {
GlossaryTermInfo glossaryTermInfo =
(GlossaryTermInfo) getAspectFromEntity(
resourceUrn.toString(), Constants.GLOSSARY_TERM_INFO_ASPECT_NAME, entityService, null);
if (glossaryTermInfo == null) {
// If there are no properties for the term already, then we should throw since the properties model also requires a name.
throw new IllegalArgumentException("Properties for this Glossary Term do not yet exist!");
}
glossaryTermInfo.setDefinition(newDescription); // We call description 'definition' for glossary terms. Not great, we know. :(
persistAspect(resourceUrn, Constants.GLOSSARY_TERM_INFO_ASPECT_NAME, glossaryTermInfo, actor, entityService);
}
public static Boolean validateFieldDescriptionInput(
Urn resourceUrn,
String subResource,
@ -111,6 +151,16 @@ public class DescriptionUtils {
return true;
}
public static Boolean validateLabelInput(
Urn resourceUrn,
EntityService entityService
) {
if (!entityService.exists(resourceUrn)) {
throw new IllegalArgumentException(String.format("Failed to update %s. %s does not exist.", resourceUrn, resourceUrn));
}
return true;
}
public static boolean isAuthorizedToUpdateFieldDescription(@Nonnull QueryContext context, Urn targetUrn) {
final DisjunctivePrivilegeGroup orPrivilegeGroups = new DisjunctivePrivilegeGroup(ImmutableList.of(
ALL_PRIVILEGES_GROUP,
@ -152,4 +202,18 @@ public class DescriptionUtils {
targetUrn.toString(),
orPrivilegeGroups);
}
public static boolean isAuthorizedToUpdateDescription(@Nonnull QueryContext context, Urn targetUrn) {
final DisjunctivePrivilegeGroup orPrivilegeGroups = new DisjunctivePrivilegeGroup(ImmutableList.of(
ALL_PRIVILEGES_GROUP,
new ConjunctivePrivilegeGroup(ImmutableList.of(PoliciesConfig.EDIT_ENTITY_DOCS_PRIVILEGE.getType()))
));
return AuthorizationUtils.isAuthorized(
context.getAuthorizer(),
context.getActorUrn(),
targetUrn.getEntityType(),
targetUrn.toString(),
orPrivilegeGroups);
}
}

View File

@ -33,6 +33,10 @@ public class UpdateDescriptionResolver implements DataFetcher<CompletableFuture<
return updateContainerDescription(targetUrn, input, environment.getContext());
case Constants.DOMAIN_ENTITY_NAME:
return updateDomainDescription(targetUrn, input, environment.getContext());
case Constants.GLOSSARY_TERM_ENTITY_NAME:
return updateGlossaryTermDescription(targetUrn, input, environment.getContext());
case Constants.TAG_ENTITY_NAME:
return updateTagDescription(targetUrn, input, environment.getContext());
default:
throw new RuntimeException(
String.format("Failed to update description. Unsupported resource type %s provided.", targetUrn));
@ -85,8 +89,8 @@ public class UpdateDescriptionResolver implements DataFetcher<CompletableFuture<
log.error("Failed to perform update against input {}, {}", input.toString(), e.getMessage());
throw new RuntimeException(String.format("Failed to perform update against input %s", input.toString()), e);
}
});
}
});
}
private CompletableFuture<Boolean> updateDatasetDescription(Urn targetUrn, DescriptionUpdateInput input, QueryContext context) {
@ -115,4 +119,52 @@ public class UpdateDescriptionResolver implements DataFetcher<CompletableFuture<
}
});
}
private CompletableFuture<Boolean> updateTagDescription(Urn targetUrn, DescriptionUpdateInput input, QueryContext context) {
return CompletableFuture.supplyAsync(() -> {
if (!DescriptionUtils.isAuthorizedToUpdateDescription(context, targetUrn)) {
throw new AuthorizationException(
"Unauthorized to perform this action. Please contact your DataHub administrator.");
}
DescriptionUtils.validateLabelInput(targetUrn, _entityService);
try {
Urn actor = CorpuserUrn.createFromString(context.getActorUrn());
DescriptionUtils.updateTagDescription(
input.getDescription(),
targetUrn,
actor,
_entityService);
return true;
} catch (Exception e) {
log.error("Failed to perform update against input {}, {}", input.toString(), e.getMessage());
throw new RuntimeException(String.format("Failed to perform update against input %s", input.toString()), e);
}
});
}
private CompletableFuture<Boolean> updateGlossaryTermDescription(Urn targetUrn, DescriptionUpdateInput input, QueryContext context) {
return CompletableFuture.supplyAsync(() -> {
if (!DescriptionUtils.isAuthorizedToUpdateDescription(context, targetUrn)) {
throw new AuthorizationException(
"Unauthorized to perform this action. Please contact your DataHub administrator.");
}
DescriptionUtils.validateLabelInput(targetUrn, _entityService);
try {
Urn actor = CorpuserUrn.createFromString(context.getActorUrn());
DescriptionUtils.updateGlossaryTermDescription(
input.getDescription(),
targetUrn,
actor,
_entityService);
return true;
} catch (Exception e) {
log.error("Failed to perform update against input {}, {}", input.toString(), e.getMessage());
throw new RuntimeException(String.format("Failed to perform update against input %s", input.toString()), e);
}
});
}
}

View File

@ -0,0 +1,101 @@
package com.linkedin.datahub.graphql.resolvers.tag;
import com.google.common.collect.ImmutableList;
import com.linkedin.common.urn.Urn;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.authorization.AuthorizationUtils;
import com.linkedin.datahub.graphql.authorization.ConjunctivePrivilegeGroup;
import com.linkedin.datahub.graphql.authorization.DisjunctivePrivilegeGroup;
import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.resolvers.AuthUtils;
import com.linkedin.entity.client.EntityClient;
import com.linkedin.events.metadata.ChangeType;
import com.linkedin.metadata.Constants;
import com.linkedin.metadata.authorization.PoliciesConfig;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.metadata.utils.GenericAspectUtils;
import com.linkedin.mxe.MetadataChangeProposal;
import com.linkedin.tag.TagProperties;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.concurrent.CompletableFuture;
import javax.annotation.Nonnull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*;
/**
* Resolver used for updating the Domain associated with a Metadata Asset. Requires the EDIT_DOMAINS privilege for a particular asset.
*/
@Slf4j
@RequiredArgsConstructor
public class SetTagColorResolver implements DataFetcher<CompletableFuture<Boolean>> {
private final EntityClient _entityClient;
private final EntityService _entityService; // TODO: Remove this when 'exists' added to EntityClient
@Override
public CompletableFuture<Boolean> get(DataFetchingEnvironment environment) throws Exception {
final QueryContext context = environment.getContext();
final Urn tagUrn = Urn.createFromString(environment.getArgument("urn"));
final String colorHex = environment.getArgument("colorHex");
return CompletableFuture.supplyAsync(() -> {
// If user is not authorized, then throw exception.
if (!isAuthorizedToSetTagColor(environment.getContext(), tagUrn)) {
throw new AuthorizationException("Unauthorized to perform this action. Please contact your DataHub administrator.");
}
// If tag does not exist, then throw exception.
if (!_entityService.exists(tagUrn)) {
throw new IllegalArgumentException(
String.format("Failed to set Tag %s color. Tag does not exist.", tagUrn));
}
try {
TagProperties tagProperties = (TagProperties) getAspectFromEntity(
tagUrn.toString(),
Constants.TAG_PROPERTIES_ASPECT_NAME,
_entityService,
null);
if (tagProperties == null) {
throw new IllegalArgumentException("Failed to set tag color. Tag properties does not yet exist!");
}
tagProperties.setColorHex(colorHex);
// Update the TagProperties aspect.
final MetadataChangeProposal proposal = new MetadataChangeProposal();
proposal.setEntityUrn(tagUrn);
proposal.setEntityType(tagUrn.getEntityType());
proposal.setAspectName(Constants.TAG_PROPERTIES_ASPECT_NAME);
proposal.setAspect(GenericAspectUtils.serializeAspect(tagProperties));
proposal.setChangeType(ChangeType.UPSERT);
_entityClient.ingestProposal(proposal, context.getAuthentication());
return true;
} catch (Exception e) {
log.error("Failed to set color for Tag with urn {}: {}", tagUrn, e.getMessage());
throw new RuntimeException(String.format("Failed to set color for Tag with urn %s", tagUrn), e);
}
});
}
public static boolean isAuthorizedToSetTagColor(@Nonnull QueryContext context, Urn entityUrn) {
final DisjunctivePrivilegeGroup orPrivilegeGroups = new DisjunctivePrivilegeGroup(ImmutableList.of(
AuthUtils.ALL_PRIVILEGES_GROUP,
new ConjunctivePrivilegeGroup(ImmutableList.of(PoliciesConfig.EDIT_TAG_COLOR_PRIVILEGE.getType()))
));
return AuthorizationUtils.isAuthorized(
context.getAuthorizer(),
context.getActorUrn(),
entityUrn.getEntityType(),
entityUrn.toString(),
orPrivilegeGroups);
}
}

View File

@ -23,7 +23,11 @@ public class GlossaryTermInfoMapper implements ModelMapper<com.linkedin.glossary
public GlossaryTermInfo apply(@Nonnull final com.linkedin.glossary.GlossaryTermInfo glossaryTermInfo) {
com.linkedin.datahub.graphql.generated.GlossaryTermInfo glossaryTermInfoResult = new com.linkedin.datahub.graphql.generated.GlossaryTermInfo();
glossaryTermInfoResult.setDefinition(glossaryTermInfo.getDefinition());
glossaryTermInfoResult.setDescription(glossaryTermInfo.getDefinition());
glossaryTermInfoResult.setTermSource(glossaryTermInfo.getTermSource());
if (glossaryTermInfo.hasName()) {
glossaryTermInfoResult.setName(glossaryTermInfo.getName());
}
if (glossaryTermInfo.hasSourceRef()) {
glossaryTermInfoResult.setSourceRef(glossaryTermInfo.getSourceRef());
}

View File

@ -34,14 +34,20 @@ public class GlossaryTermMapper implements ModelMapper<EntityResponse, GlossaryT
final GlossaryTerm result = new GlossaryTerm();
result.setUrn(entityResponse.getUrn().toString());
result.setType(EntityType.GLOSSARY_TERM);
entityResponse.getAspects().forEach((name, aspect) -> {
DataMap data = aspect.getValue().data();
if (GLOSSARY_TERM_KEY_ASPECT_NAME.equals(name)) {
final GlossaryTermKey gmsKey = new GlossaryTermKey(data);
result.setName(GlossaryTermUtils.getGlossaryTermName(gmsKey.getName()));
// Construct the legacy name from the URN itself.
final String legacyName = GlossaryTermUtils.getGlossaryTermName(entityResponse.getUrn().getId());
result.setName(legacyName);
result.setHierarchicalName(gmsKey.getName());
} else if (GLOSSARY_TERM_INFO_ASPECT_NAME.equals(name)) {
// Set deprecation info field.
result.setGlossaryTermInfo(GlossaryTermInfoMapper.map(new GlossaryTermInfo(data)));
// Set new properties field.
result.setProperties(GlossaryTermPropertiesMapper.map(new GlossaryTermInfo(data)));
} else if (OWNERSHIP_ASPECT_NAME.equals(name)) {
result.setOwnership(OwnershipMapper.map(new Ownership(data)));
} else if (DEPRECATION_ASPECT_NAME.equals(name)) {

View File

@ -0,0 +1,42 @@
package com.linkedin.datahub.graphql.types.glossary.mappers;
import com.linkedin.datahub.graphql.generated.GlossaryTermProperties;
import javax.annotation.Nonnull;
import com.linkedin.datahub.graphql.types.common.mappers.StringMapMapper;
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
/**
* Maps Pegasus {@link RecordTemplate} objects to objects conforming to the GQL schema.
*
* To be replaced by auto-generated mappers implementations
*/
public class GlossaryTermPropertiesMapper implements ModelMapper<com.linkedin.glossary.GlossaryTermInfo, GlossaryTermProperties> {
public static final GlossaryTermPropertiesMapper INSTANCE = new GlossaryTermPropertiesMapper();
public static GlossaryTermProperties map(@Nonnull final com.linkedin.glossary.GlossaryTermInfo glossaryTermInfo) {
return INSTANCE.apply(glossaryTermInfo);
}
@Override
public GlossaryTermProperties apply(@Nonnull final com.linkedin.glossary.GlossaryTermInfo glossaryTermInfo) {
com.linkedin.datahub.graphql.generated.GlossaryTermProperties result = new com.linkedin.datahub.graphql.generated.GlossaryTermProperties();
result.setDefinition(glossaryTermInfo.getDefinition());
result.setDescription(glossaryTermInfo.getDefinition());
result.setTermSource(glossaryTermInfo.getTermSource());
if (glossaryTermInfo.hasName()) {
result.setName(glossaryTermInfo.getName());
}
if (glossaryTermInfo.hasSourceRef()) {
result.setSourceRef(glossaryTermInfo.getSourceRef());
}
if (glossaryTermInfo.hasSourceUrl()) {
result.setSourceUrl(glossaryTermInfo.getSourceUrl().toString());
}
if (glossaryTermInfo.hasCustomProperties()) {
result.setCustomProperties(StringMapMapper.map(glossaryTermInfo.getCustomProperties()));
}
return result;
}
}

View File

@ -2,6 +2,7 @@ package com.linkedin.datahub.graphql.types.tag.mappers;
import com.datahub.util.ModelUtils;
import com.linkedin.common.Ownership;
import com.linkedin.data.template.GetMode;
import com.linkedin.datahub.graphql.generated.EntityType;
import com.linkedin.datahub.graphql.generated.Tag;
import com.linkedin.datahub.graphql.types.common.mappers.OwnershipMapper;
@ -33,7 +34,10 @@ public class TagSnapshotMapper implements ModelMapper<TagSnapshot, Tag> {
ModelUtils.getAspectsFromSnapshot(tag).forEach(aspect -> {
if (aspect instanceof TagProperties) {
if (TagProperties.class.cast(aspect).hasDescription()) {
final TagProperties properties = (TagProperties) aspect;
result.setProperties(mapTagProperties(properties));
// Set deprecated top-level description field.
if (properties.hasDescription()) {
result.setDescription(TagProperties.class.cast(aspect).getDescription());
}
} else if (aspect instanceof Ownership) {
@ -42,4 +46,12 @@ public class TagSnapshotMapper implements ModelMapper<TagSnapshot, Tag> {
});
return result;
}
private com.linkedin.datahub.graphql.generated.TagProperties mapTagProperties(final TagProperties gmsProperties) {
final com.linkedin.datahub.graphql.generated.TagProperties result = new com.linkedin.datahub.graphql.generated.TagProperties();
result.setName(gmsProperties.getName(GetMode.DEFAULT));
result.setDescription(gmsProperties.getDescription(GetMode.DEFAULT));
result.setColorHex(gmsProperties.getColorHex(GetMode.DEFAULT));
return result;
}
}

View File

@ -155,6 +155,11 @@ type Mutation {
"""
updateTag(urn: String!, input: TagUpdateInput!): Tag
"""
Set the hex color associated with an existing Tag
"""
setTagColor(urn: String!, colorHex: String!): Boolean
"""
Create a policy and returns the resulting urn
"""
@ -793,7 +798,7 @@ type GlossaryTerm implements Entity {
urn: String!
"""
Ownership metadata of the dataset
Ownership metadata of the glossary term
"""
ownership: Ownership
@ -803,9 +808,9 @@ type GlossaryTerm implements Entity {
type: EntityType!
"""
Display name of the glossary term
Name / id of the glossary term
"""
name: String!
name: String! @deprecated
"""
hierarchicalName of glossary term
@ -846,9 +851,19 @@ Information about a glossary term
"""
type GlossaryTermInfo {
"""
Definition of the glossary term
The name of the Glossary Term
"""
definition: String!
name: String
"""
Description of the glossary term
"""
description: String!
"""
Definition of the glossary term. Deprecated - Use 'description' instead.
"""
definition: String! @deprecated
"""
Term Source of the glossary term
@ -881,9 +896,19 @@ Additional read only properties about a Glossary Term
"""
type GlossaryTermProperties {
"""
Definition of the glossary term
The name of the Glossary Term
"""
definition: String!
name: String
"""
Description of the glossary term
"""
description: String!
"""
Definition of the glossary term. Deprecated - Use 'description' instead.
"""
definition: String! @deprecated
"""
Term Source of the glossary term
@ -2500,14 +2525,20 @@ type Tag implements Entity {
type: EntityType!
"""
The display name of the tag
The name / id of the tag. Use properties.name instead.
"""
name: String!
name: String! @deprecated
"""
Additional properties about the Tag
"""
properties: TagProperties
"""
Additional read write properties about the Tag
Deprecated! Use 'properties' field instead.
"""
editableProperties: EditableTagProperties
editableProperties: EditableTagProperties @deprecated
"""
Ownership metadata of the dataset
@ -2520,22 +2551,48 @@ type Tag implements Entity {
relationships(input: RelationshipsInput!): EntityRelationshipsResult
"""
Deprecated, use editableProperties field instead
Description of the tag
Deprecated, use properties.description field instead
"""
description: String @deprecated
}
"""
Additional read write Tag properties
Deprecated! Replaced by TagProperties.
"""
type EditableTagProperties {
"""
A display name for the Tag
"""
name: String
"""
A description of the Tag
"""
description: String
}
"""
Properties for a DataHub Tag
"""
type TagProperties {
"""
A display name for the Tag
"""
name: String
"""
A description of the Tag
"""
description: String
"""
An optional RGB hex code for a Tag color, e.g. #FFFFFF
"""
colorHex: String
}
"""
An edge between a Metadata Entity and a Tag Modeled as a struct to permit
additional attributes

View File

@ -0,0 +1,186 @@
package com.linkedin.datahub.graphql.resolvers.tag;
import com.datahub.authentication.Authentication;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.linkedin.common.urn.Urn;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.entity.Aspect;
import com.linkedin.entity.EntityResponse;
import com.linkedin.entity.EnvelopedAspect;
import com.linkedin.entity.EnvelopedAspectMap;
import com.linkedin.entity.client.EntityClient;
import com.linkedin.events.metadata.ChangeType;
import com.linkedin.metadata.Constants;
import com.linkedin.metadata.entity.EntityService;
import com.linkedin.metadata.utils.GenericAspectUtils;
import com.linkedin.mxe.MetadataChangeProposal;
import com.linkedin.r2.RemoteInvocationException;
import com.linkedin.tag.TagProperties;
import graphql.schema.DataFetchingEnvironment;
import java.util.HashSet;
import java.util.concurrent.CompletionException;
import org.mockito.Mockito;
import org.testng.annotations.Test;
import static com.linkedin.datahub.graphql.TestUtils.*;
import static org.testng.Assert.*;
public class SetTagColorResolverTest {
private static final String TEST_ENTITY_URN = "urn:li:tag:test-tag";
private static final String TEST_COLOR_HEX = "#FFFFFF";
@Test
public void testGetSuccessExistingProperties() throws Exception {
// Create resolver
EntityClient mockClient = Mockito.mock(EntityClient.class);
EntityService mockService = Mockito.mock(EntityService.class);
// Test setting the domain
final TagProperties oldTagProperties = new TagProperties().setName("Test Tag");
Mockito.when(mockService.getAspect(
Mockito.eq(Urn.createFromString(TEST_ENTITY_URN)),
Mockito.eq(Constants.TAG_PROPERTIES_ASPECT_NAME),
Mockito.eq(0L)))
.thenReturn(oldTagProperties);
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true);
SetTagColorResolver resolver = new SetTagColorResolver(mockClient, mockService);
// Execute resolver
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ENTITY_URN);
Mockito.when(mockEnv.getArgument(Mockito.eq("colorHex"))).thenReturn(TEST_COLOR_HEX);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
resolver.get(mockEnv).get();
final TagProperties newTagProperties = new TagProperties().setName("Test Tag").setColorHex(TEST_COLOR_HEX);
final MetadataChangeProposal proposal = new MetadataChangeProposal();
proposal.setEntityUrn(Urn.createFromString(TEST_ENTITY_URN));
proposal.setEntityType(Constants.TAG_ENTITY_NAME);
proposal.setAspectName(Constants.TAG_PROPERTIES_ASPECT_NAME);
proposal.setAspect(GenericAspectUtils.serializeAspect(newTagProperties));
proposal.setChangeType(ChangeType.UPSERT);
Mockito.verify(mockClient, Mockito.times(1)).ingestProposal(
Mockito.eq(proposal),
Mockito.any(Authentication.class)
);
Mockito.verify(mockService, Mockito.times(1)).exists(
Mockito.eq(Urn.createFromString(TEST_ENTITY_URN))
);
}
@Test
public void testGetFailureNoExistingProperties() throws Exception {
// Create resolver
EntityClient mockClient = Mockito.mock(EntityClient.class);
EntityService mockService = Mockito.mock(EntityService.class);
// Test setting the domain
Mockito.when(mockService.getAspect(
Mockito.eq(Urn.createFromString(TEST_ENTITY_URN)),
Mockito.eq(Constants.TAG_PROPERTIES_ASPECT_NAME),
Mockito.eq(0)))
.thenReturn(null);
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(true);
SetTagColorResolver resolver = new SetTagColorResolver(mockClient, mockService);
// Execute resolver
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ENTITY_URN);
Mockito.when(mockEnv.getArgument(Mockito.eq("colorHex"))).thenReturn(TEST_COLOR_HEX);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join());
Mockito.verify(mockClient, Mockito.times(0)).ingestProposal(
Mockito.any(),
Mockito.any(Authentication.class));
}
@Test
public void testGetFailureTagDoesNotExist() throws Exception {
// Create resolver
EntityClient mockClient = Mockito.mock(EntityClient.class);
// Test setting the domain
final TagProperties oldTagProperties = new TagProperties().setName("Test Tag");
final EnvelopedAspect oldTagPropertiesAspect = new EnvelopedAspect()
.setName(Constants.TAG_PROPERTIES_ASPECT_NAME)
.setValue(new Aspect(oldTagProperties.data()));
Mockito.when(mockClient.batchGetV2(
Mockito.eq(Constants.TAG_ENTITY_NAME),
Mockito.eq(new HashSet<>(ImmutableSet.of(Urn.createFromString(TEST_ENTITY_URN)))),
Mockito.eq(ImmutableSet.of(Constants.TAG_PROPERTIES_ASPECT_NAME)),
Mockito.any(Authentication.class)))
.thenReturn(ImmutableMap.of(Urn.createFromString(TEST_ENTITY_URN),
new EntityResponse()
.setEntityName(Constants.TAG_ENTITY_NAME)
.setUrn(Urn.createFromString(TEST_ENTITY_URN))
.setAspects(new EnvelopedAspectMap(ImmutableMap.of(
Constants.TAG_PROPERTIES_ASPECT_NAME,
oldTagPropertiesAspect)))));
EntityService mockService = Mockito.mock(EntityService.class);
Mockito.when(mockService.exists(Urn.createFromString(TEST_ENTITY_URN))).thenReturn(false);
SetTagColorResolver resolver = new SetTagColorResolver(mockClient, mockService);
// Execute resolver
QueryContext mockContext = getMockAllowContext();
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ENTITY_URN);
Mockito.when(mockEnv.getArgument(Mockito.eq("colorHex"))).thenReturn(TEST_COLOR_HEX);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join());
Mockito.verify(mockClient, Mockito.times(0)).ingestProposal(
Mockito.any(),
Mockito.any(Authentication.class));
}
@Test
public void testGetUnauthorized() throws Exception {
// Create resolver
EntityClient mockClient = Mockito.mock(EntityClient.class);
EntityService mockService = Mockito.mock(EntityService.class);
SetTagColorResolver resolver = new SetTagColorResolver(mockClient, mockService);
// Execute resolver
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ENTITY_URN);
Mockito.when(mockEnv.getArgument(Mockito.eq("colorHex"))).thenReturn(TEST_COLOR_HEX);
QueryContext mockContext = getMockDenyContext();
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join());
Mockito.verify(mockClient, Mockito.times(0)).ingestProposal(
Mockito.any(),
Mockito.any(Authentication.class));
}
@Test
public void testGetEntityClientException() throws Exception {
EntityClient mockClient = Mockito.mock(EntityClient.class);
Mockito.doThrow(RemoteInvocationException.class).when(mockClient).ingestProposal(
Mockito.any(),
Mockito.any(Authentication.class));
SetTagColorResolver resolver = new SetTagColorResolver(mockClient, Mockito.mock(EntityService.class));
// Execute resolver
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
QueryContext mockContext = getMockAllowContext();
Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))).thenReturn(TEST_ENTITY_URN);
Mockito.when(mockEnv.getArgument(Mockito.eq("colorHex"))).thenReturn(TEST_COLOR_HEX);
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join());
}
}

View File

@ -336,7 +336,9 @@ export const dataset3 = {
urn: 'urn:li:glossaryTerm:sample-glossary-term',
name: 'sample-glossary-term',
hierarchicalName: 'example.sample-glossary-term',
glossaryTermInfo: {
properties: {
name: 'sample-glossary-term',
description: 'sample definition',
definition: 'sample definition',
termSource: 'sample term source',
},
@ -704,6 +706,16 @@ const glossaryTerm1 = {
},
},
glossaryTermInfo: {
name: 'Another glossary term',
description: 'New glossary term',
definition: 'New glossary term',
termSource: 'termSource',
sourceRef: 'sourceRef',
sourceURI: 'sourceURI',
},
properties: {
name: 'Another glossary term',
description: 'New glossary term',
definition: 'New glossary term',
termSource: 'termSource',
sourceRef: 'sourceRef',
@ -718,6 +730,8 @@ const glossaryTerm2 = {
hierarchicalName: 'example.glossaryterm1',
ownership: null,
glossaryTermInfo: {
name: 'glossaryterm1',
description: 'is A relation glossary term 1',
definition: 'is A relation glossary term 1',
termSource: 'INTERNAL',
sourceRef: 'TERM_SOURCE_SAXO',
@ -732,6 +746,23 @@ const glossaryTerm2 = {
],
__typename: 'GlossaryTermInfo',
},
properties: {
name: 'glossaryterm1',
description: 'is A relation glossary term 1',
definition: 'is A relation glossary term 1',
termSource: 'INTERNAL',
sourceRef: 'TERM_SOURCE_SAXO',
sourceUrl: '',
rawSchema: 'sample proto schema',
customProperties: [
{
key: 'keyProperty',
value: 'valueProperty',
__typename: 'StringMapEntry',
},
],
__typename: 'GlossaryTermProperties',
},
isRealtedTerms: {
start: 0,
count: 0,
@ -770,6 +801,8 @@ const glossaryTerm3 = {
hierarchicalName: 'example.glossaryterm2',
ownership: null,
glossaryTermInfo: {
name: 'glossaryterm2',
description: 'has A relation glossary term 2',
definition: 'has A relation glossary term 2',
termSource: 'INTERNAL',
sourceRef: 'TERM_SOURCE_SAXO',
@ -784,6 +817,23 @@ const glossaryTerm3 = {
],
__typename: 'GlossaryTermInfo',
},
properties: {
name: 'glossaryterm2',
description: 'has A relation glossary term 2',
definition: 'has A relation glossary term 2',
termSource: 'INTERNAL',
sourceRef: 'TERM_SOURCE_SAXO',
sourceUrl: '',
rawSchema: 'sample proto schema',
customProperties: [
{
key: 'keyProperty',
value: 'valueProperty',
__typename: 'StringMapEntry',
},
],
__typename: 'GlossaryTermProperties',
},
glossaryRelatedTerms: {
isRelatedTerms: null,
hasRelatedTerms: [

View File

@ -124,7 +124,9 @@ export const sampleSchemaWithTags: Schema = {
urn: 'urn:li:glossaryTerm:sample-glossary-term',
name: 'sample-glossary-term',
hierarchicalName: 'example.sample-glossary-term',
glossaryTermInfo: {
properties: {
name: 'sample-glossary-term',
description: 'sample definition',
definition: 'sample definition',
termSource: 'sample term source',
},
@ -246,7 +248,9 @@ export const sampleSchemaWithPkFk: SchemaMetadata = {
urn: 'urn:li:glossaryTerm:sample-glossary-term',
name: 'sample-glossary-term',
hierarchicalName: 'example.sample-glossary-term',
glossaryTermInfo: {
properties: {
name: 'sample-glossary-term',
description: 'sample definition',
definition: 'sample definition',
termSource: 'sample term source',
},

View File

@ -45,9 +45,6 @@ export const SetDomainModal = ({ visible, onClose, refetch }: Props) => {
const domainSearchResults = domainSearchData?.search?.searchResults || [];
const [setDomainMutation] = useSetDomainMutation();
console.log('Re rendering');
console.log(domainSearchResults);
const inputEl = useRef(null);
const onOk = async () => {
@ -156,7 +153,6 @@ export const SetDomainModal = ({ visible, onClose, refetch }: Props) => {
tagRender={(tagProps) => <Tag>{tagProps.value}</Tag>}
>
{domainSearchResults.map((result) => {
console.log(result);
return (
<Select.Option value={result.entity.urn}>{renderSearchResult(result)}</Select.Option>
);

View File

@ -185,6 +185,7 @@ fragment entityPreview on Entity {
}
... on GlossaryTerm {
name
hierarchicalName
glossaryTermInfo {
definition
termSource

View File

@ -13,7 +13,17 @@ import com.linkedin.common.CustomProperties
record GlossaryTermInfo includes CustomProperties {
/**
* Definition of business term
* Display name of the term
*/
@Searchable = {
"fieldType": "TEXT_PARTIAL",
"enableAutocomplete": true,
"boostScore": 10.0
}
name: optional string
/**
* Definition of business term.
*/
@Searchable = {}
definition: string

View File

@ -9,12 +9,14 @@ import com.linkedin.common.Urn
"name": "glossaryTermKey"
}
record GlossaryTermKey {
/**
* The term name, which serves as a unique id
*/
@Searchable = {
"fieldType": "TEXT_PARTIAL",
"enableAutocomplete": true
"enableAutocomplete": true,
"fieldName": "id"
}
name: string
}

View File

@ -8,12 +8,13 @@ namespace com.linkedin.metadata.key
}
record TagKey {
/**
* The unique tag name
* The tag name, which serves as a unique id
*/
@Searchable = {
"fieldType": "TEXT_PARTIAL",
"enableAutocomplete": true,
"boostScore": 10.0
"boostScore": 10.0,
"fieldName": "id"
}
name: string
}

View File

@ -7,14 +7,24 @@ namespace com.linkedin.tag
"name": "tagProperties"
}
record TagProperties {
/**
* Name of the tag
* Display name of the tag
*/
@Searchable = {
"fieldType": "TEXT_PARTIAL",
"enableAutocomplete": true,
"boostScore": 10.0
}
name: string
/**
* Documentation of the tag
*/
@Searchable = {}
description: optional string
/**
* The color associated with the Tag in Hex. For example #FFFFFF.
*/
colorHex: optional string
}

View File

@ -66,11 +66,18 @@ entities:
- browsePaths # unclear if this will be used
- status
- domains
- name: tag
keyAspect: tagKey
aspects:
- tagProperties
- ownership
- deprecation
- name: glossaryTerm
keyAspect: glossaryTermKey
aspects:
- glossaryTermInfo
- schemaMetadata
- ownership
- deprecation
- name: domain
doc: A data domain within an organization.

View File

@ -1524,9 +1524,19 @@
"doc" : "Properties associated with a GlossaryTerm",
"include" : [ "com.linkedin.common.CustomProperties" ],
"fields" : [ {
"name" : "name",
"type" : "string",
"doc" : "Display name of the term",
"optional" : true,
"Searchable" : {
"boostScore" : 10.0,
"enableAutocomplete" : true,
"fieldType" : "TEXT_PARTIAL"
}
}, {
"name" : "definition",
"type" : "string",
"doc" : "Definition of business term",
"doc" : "Definition of business term.",
"Searchable" : { }
}, {
"name" : "parentNode",
@ -2504,8 +2514,10 @@
"fields" : [ {
"name" : "name",
"type" : "string",
"doc" : "The term name, which serves as a unique id",
"Searchable" : {
"enableAutocomplete" : true,
"fieldName" : "id",
"fieldType" : "TEXT_PARTIAL"
}
} ],
@ -3141,10 +3153,11 @@
"fields" : [ {
"name" : "name",
"type" : "string",
"doc" : "The unique tag name",
"doc" : "The tag name, which serves as a unique id",
"Searchable" : {
"boostScore" : 10.0,
"enableAutocomplete" : true,
"fieldName" : "id",
"fieldType" : "TEXT_PARTIAL"
}
} ],
@ -3159,11 +3172,22 @@
"fields" : [ {
"name" : "name",
"type" : "string",
"doc" : "Name of the tag"
"doc" : "Display name of the tag",
"Searchable" : {
"boostScore" : 10.0,
"enableAutocomplete" : true,
"fieldType" : "TEXT_PARTIAL"
}
}, {
"name" : "description",
"type" : "string",
"doc" : "Documentation of the tag",
"optional" : true,
"Searchable" : { }
}, {
"name" : "colorHex",
"type" : "string",
"doc" : "The color associated with the Tag in Hex. For example #FFFFFF.",
"optional" : true
} ],
"Aspect" : {

View File

@ -4064,10 +4064,11 @@
"fields" : [ {
"name" : "name",
"type" : "string",
"doc" : "The unique tag name",
"doc" : "The tag name, which serves as a unique id",
"Searchable" : {
"boostScore" : 10.0,
"enableAutocomplete" : true,
"fieldName" : "id",
"fieldType" : "TEXT_PARTIAL"
}
} ],
@ -4082,11 +4083,22 @@
"fields" : [ {
"name" : "name",
"type" : "string",
"doc" : "Name of the tag"
"doc" : "Display name of the tag",
"Searchable" : {
"boostScore" : 10.0,
"enableAutocomplete" : true,
"fieldType" : "TEXT_PARTIAL"
}
}, {
"name" : "description",
"type" : "string",
"doc" : "Documentation of the tag",
"optional" : true,
"Searchable" : { }
}, {
"name" : "colorHex",
"type" : "string",
"doc" : "The color associated with the Tag in Hex. For example #FFFFFF.",
"optional" : true
} ],
"Aspect" : {
@ -4126,8 +4138,10 @@
"fields" : [ {
"name" : "name",
"type" : "string",
"doc" : "The term name, which serves as a unique id",
"Searchable" : {
"enableAutocomplete" : true,
"fieldName" : "id",
"fieldType" : "TEXT_PARTIAL"
}
} ],
@ -4141,9 +4155,19 @@
"doc" : "Properties associated with a GlossaryTerm",
"include" : [ "com.linkedin.common.CustomProperties" ],
"fields" : [ {
"name" : "name",
"type" : "string",
"doc" : "Display name of the term",
"optional" : true,
"Searchable" : {
"boostScore" : 10.0,
"enableAutocomplete" : true,
"fieldType" : "TEXT_PARTIAL"
}
}, {
"name" : "definition",
"type" : "string",
"doc" : "Definition of business term",
"doc" : "Definition of business term.",
"Searchable" : { }
}, {
"name" : "parentNode",

View File

@ -1281,9 +1281,19 @@
"doc" : "Properties associated with a GlossaryTerm",
"include" : [ "com.linkedin.common.CustomProperties" ],
"fields" : [ {
"name" : "name",
"type" : "string",
"doc" : "Display name of the term",
"optional" : true,
"Searchable" : {
"boostScore" : 10.0,
"enableAutocomplete" : true,
"fieldType" : "TEXT_PARTIAL"
}
}, {
"name" : "definition",
"type" : "string",
"doc" : "Definition of business term",
"doc" : "Definition of business term.",
"Searchable" : { }
}, {
"name" : "parentNode",
@ -2261,8 +2271,10 @@
"fields" : [ {
"name" : "name",
"type" : "string",
"doc" : "The term name, which serves as a unique id",
"Searchable" : {
"enableAutocomplete" : true,
"fieldName" : "id",
"fieldType" : "TEXT_PARTIAL"
}
} ],
@ -2898,10 +2910,11 @@
"fields" : [ {
"name" : "name",
"type" : "string",
"doc" : "The unique tag name",
"doc" : "The tag name, which serves as a unique id",
"Searchable" : {
"boostScore" : 10.0,
"enableAutocomplete" : true,
"fieldName" : "id",
"fieldType" : "TEXT_PARTIAL"
}
} ],
@ -2916,11 +2929,22 @@
"fields" : [ {
"name" : "name",
"type" : "string",
"doc" : "Name of the tag"
"doc" : "Display name of the tag",
"Searchable" : {
"boostScore" : 10.0,
"enableAutocomplete" : true,
"fieldType" : "TEXT_PARTIAL"
}
}, {
"name" : "description",
"type" : "string",
"doc" : "Documentation of the tag",
"optional" : true,
"Searchable" : { }
}, {
"name" : "colorHex",
"type" : "string",
"doc" : "The color associated with the Tag in Hex. For example #FFFFFF.",
"optional" : true
} ],
"Aspect" : {

View File

@ -29,6 +29,8 @@ public class Constants {
public static final String INGESTION_SOURCE_ENTITY_NAME = "dataHubIngestionSource";
public static final String SECRETS_ENTITY_NAME = "dataHubSecret";
public static final String EXECUTION_REQUEST_ENTITY_NAME = "dataHubExecutionRequest";
public static final String TAG_ENTITY_NAME = "tag";
/**
* Aspects
@ -98,6 +100,9 @@ public class Constants {
public static final String GLOSSARY_TERM_INFO_ASPECT_NAME = "glossaryTermInfo";
public static final String GLOSSARY_RELATED_TERM_ASPECT_NAME = "glossaryRelatedTerms";
// Tag
public static final String TAG_PROPERTIES_ASPECT_NAME = "tagProperties";
// Domain
public static final String DOMAIN_KEY_ASPECT_NAME = "domainKey";
public static final String DOMAIN_PROPERTIES_ASPECT_NAME = "domainProperties";

View File

@ -147,6 +147,12 @@ public class PoliciesConfig {
"The ability to edit the column (field) descriptions associated with a dataset schema."
);
// Tag Privileges
public static final Privilege EDIT_TAG_COLOR_PRIVILEGE = Privilege.of(
"EDIT_TAG_COLOR",
"Edit Tag Color",
"The ability to change the color of a Tag.");
public static final ResourcePrivileges DATASET_PRIVILEGES = ResourcePrivileges.of(
"dataset",
"Datasets",
@ -194,7 +200,7 @@ public class PoliciesConfig {
"tag",
"Tags",
"Tags indexed by DataHub",
ImmutableList.of(EDIT_ENTITY_OWNERS_PRIVILEGE, EDIT_ENTITY_PRIVILEGE)
ImmutableList.of(EDIT_ENTITY_OWNERS_PRIVILEGE, EDIT_TAG_COLOR_PRIVILEGE, EDIT_ENTITY_DOCS_PRIVILEGE, EDIT_ENTITY_PRIVILEGE)
);
// Container Privileges