diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index 51021bf2a29..0bd8c791dab 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -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))) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/DescriptionUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/DescriptionUtils.java index 8cd5cb34935..704b1bd2c13 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/DescriptionUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/DescriptionUtils.java @@ -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); + } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateDescriptionResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateDescriptionResolver.java index eaca5f5e9e1..966a9bde73a 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateDescriptionResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/mutate/UpdateDescriptionResolver.java @@ -33,6 +33,10 @@ public class UpdateDescriptionResolver implements DataFetcher updateDatasetDescription(Urn targetUrn, DescriptionUpdateInput input, QueryContext context) { @@ -115,4 +119,52 @@ public class UpdateDescriptionResolver implements DataFetcher 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 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); + } + }); + } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolver.java new file mode 100644 index 00000000000..6fee51bd606 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolver.java @@ -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> { + + private final EntityClient _entityClient; + private final EntityService _entityService; // TODO: Remove this when 'exists' added to EntityClient + + @Override + public CompletableFuture 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); + } +} \ No newline at end of file diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermInfoMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermInfoMapper.java index ab8d7de5c58..9885cce7176 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermInfoMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermInfoMapper.java @@ -23,7 +23,11 @@ public class GlossaryTermInfoMapper implements ModelMapper { 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)) { diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermPropertiesMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermPropertiesMapper.java new file mode 100644 index 00000000000..d56f63ac885 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/glossary/mappers/GlossaryTermPropertiesMapper.java @@ -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 { + + 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; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/tag/mappers/TagSnapshotMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/tag/mappers/TagSnapshotMapper.java index 04c0bbe98de..730c5212468 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/tag/mappers/TagSnapshotMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/tag/mappers/TagSnapshotMapper.java @@ -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 { 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 { }); 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; + } } diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index a75181c78c4..0bf3ce077e1 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -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 diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolverTest.java new file mode 100644 index 00000000000..6c0f5bd1773 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/tag/SetTagColorResolverTest.java @@ -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()); + } +} \ No newline at end of file diff --git a/datahub-web-react/src/Mocks.tsx b/datahub-web-react/src/Mocks.tsx index d1ff9ec03e3..f5f48402262 100644 --- a/datahub-web-react/src/Mocks.tsx +++ b/datahub-web-react/src/Mocks.tsx @@ -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: [ diff --git a/datahub-web-react/src/app/entity/dataset/profile/stories/sampleSchema.ts b/datahub-web-react/src/app/entity/dataset/profile/stories/sampleSchema.ts index 8afacdbcdd0..8ee30647e50 100644 --- a/datahub-web-react/src/app/entity/dataset/profile/stories/sampleSchema.ts +++ b/datahub-web-react/src/app/entity/dataset/profile/stories/sampleSchema.ts @@ -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', }, diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/Domain/SetDomainModal.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/Domain/SetDomainModal.tsx index cea090dde39..4f16a9bead0 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/Domain/SetDomainModal.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/sidebar/Domain/SetDomainModal.tsx @@ -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) => {tagProps.value}} > {domainSearchResults.map((result) => { - console.log(result); return ( {renderSearchResult(result)} ); diff --git a/datahub-web-react/src/graphql/preview.graphql b/datahub-web-react/src/graphql/preview.graphql index 3833f1c579a..c6c0db3c18a 100644 --- a/datahub-web-react/src/graphql/preview.graphql +++ b/datahub-web-react/src/graphql/preview.graphql @@ -185,6 +185,7 @@ fragment entityPreview on Entity { } ... on GlossaryTerm { name + hierarchicalName glossaryTermInfo { definition termSource diff --git a/metadata-models/src/main/pegasus/com/linkedin/glossary/GlossaryTermInfo.pdl b/metadata-models/src/main/pegasus/com/linkedin/glossary/GlossaryTermInfo.pdl index f23a797304f..25d2f3a802e 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/glossary/GlossaryTermInfo.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/glossary/GlossaryTermInfo.pdl @@ -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 diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/key/GlossaryTermKey.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/key/GlossaryTermKey.pdl index ea6825c1c95..a9f35146da1 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/metadata/key/GlossaryTermKey.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/key/GlossaryTermKey.pdl @@ -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 - } \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/key/TagKey.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/key/TagKey.pdl index 2dfb19a2d21..47f1a631b4a 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/metadata/key/TagKey.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/key/TagKey.pdl @@ -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 } \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/tag/TagProperties.pdl b/metadata-models/src/main/pegasus/com/linkedin/tag/TagProperties.pdl index cacdbd20326..41c500c6fff 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/tag/TagProperties.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/tag/TagProperties.pdl @@ -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 } diff --git a/metadata-models/src/main/resources/entity-registry.yml b/metadata-models/src/main/resources/entity-registry.yml index cd0d74bb1e9..07b5796e0f2 100644 --- a/metadata-models/src/main/resources/entity-registry.yml +++ b/metadata-models/src/main/resources/entity-registry.yml @@ -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. diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json index bb456a342ad..cf1a0c8aae2 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.aspects.snapshot.json @@ -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" : { diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json index 3cf63c064f0..b4c3bb58b2b 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.entities.snapshot.json @@ -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", diff --git a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json index 5194206d2e2..bfcfde447b1 100644 --- a/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json +++ b/metadata-service/restli-api/src/main/snapshot/com.linkedin.entity.runs.snapshot.json @@ -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" : { diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/Constants.java b/metadata-utils/src/main/java/com/linkedin/metadata/Constants.java index 548d871c674..184495aef1b 100644 --- a/metadata-utils/src/main/java/com/linkedin/metadata/Constants.java +++ b/metadata-utils/src/main/java/com/linkedin/metadata/Constants.java @@ -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"; diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java b/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java index f94ac66bdba..7436146b5d6 100644 --- a/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java +++ b/metadata-utils/src/main/java/com/linkedin/metadata/authorization/PoliciesConfig.java @@ -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