diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java index 7d56666eaa5f89..24060548c6a3d5 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java @@ -35,6 +35,7 @@ private Constants() {} public static final String LOGICAL_SCHEMA_FILE = "logical.graphql"; public static final String SETTINGS_SCHEMA_FILE = "settings.graphql"; public static final String FILES_SCHEMA_FILE = "files.graphql"; + public static final String KNOWLEDGE_SCHEMA_FILE = "knowledge.graphql"; public static final String QUERY_SCHEMA_FILE = "query.graphql"; public static final String TEMPLATE_SCHEMA_FILE = "template.graphql"; 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 49c28e3335e7d1..dbdf9d340f5351 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 @@ -300,6 +300,7 @@ import com.linkedin.datahub.graphql.types.incident.IncidentType; import com.linkedin.datahub.graphql.types.ingestion.ExecutionRequestType; import com.linkedin.datahub.graphql.types.ingestion.IngestionSourceType; +import com.linkedin.datahub.graphql.types.knowledge.DocumentType; import com.linkedin.datahub.graphql.types.mlmodel.MLFeatureTableType; import com.linkedin.datahub.graphql.types.mlmodel.MLFeatureType; import com.linkedin.datahub.graphql.types.mlmodel.MLModelGroupType; @@ -340,6 +341,7 @@ import com.linkedin.metadata.service.BusinessAttributeService; import com.linkedin.metadata.service.DataHubFileService; import com.linkedin.metadata.service.DataProductService; +import com.linkedin.metadata.service.DocumentService; import com.linkedin.metadata.service.ERModelRelationshipService; import com.linkedin.metadata.service.FormService; import com.linkedin.metadata.service.LineageService; @@ -423,6 +425,7 @@ public class GmsGraphQLEngine { private final RestrictedService restrictedService; private ConnectionService connectionService; private AssertionService assertionService; + private final DocumentService documentService; private final EntityVersioningService entityVersioningService; private final ApplicationService applicationService; private final PageTemplateService pageTemplateService; @@ -469,6 +472,7 @@ public class GmsGraphQLEngine { private final DataHubConnectionType connectionType; private final ContainerType containerType; private final DomainType domainType; + private final DocumentType documentType; private final NotebookType notebookType; private final AssertionType assertionType; private final VersionedDatasetType versionedDatasetType; @@ -573,6 +577,7 @@ public GmsGraphQLEngine(final GmsGraphQLEngineArgs args) { this.restrictedService = args.restrictedService; this.connectionService = args.connectionService; this.assertionService = args.assertionService; + this.documentService = args.documentService; this.entityVersioningService = args.entityVersioningService; this.businessAttributeService = args.businessAttributeService; @@ -612,6 +617,7 @@ public GmsGraphQLEngine(final GmsGraphQLEngineArgs args) { this.connectionType = new DataHubConnectionType(entityClient, secretService); this.containerType = new ContainerType(entityClient); this.domainType = new DomainType(entityClient); + this.documentType = new DocumentType(entityClient); this.notebookType = new NotebookType(entityClient); this.assertionType = new AssertionType(entityClient); this.versionedDatasetType = new VersionedDatasetType(entityClient); @@ -671,6 +677,7 @@ public GmsGraphQLEngine(final GmsGraphQLEngineArgs args) { containerType, notebookType, domainType, + documentType, assertionType, versionedDatasetType, dataPlatformInstanceType, @@ -774,6 +781,7 @@ public void configureRuntimeWiring(final RuntimeWiring.Builder builder) { configureOrganisationRoleResolvers(builder); configureGlossaryNodeResolvers(builder); configureDomainResolvers(builder); + configureDocumentResolvers(builder); configureDataProductResolvers(builder); configureApplicationResolvers(builder); configureAssertionResolvers(builder); @@ -872,7 +880,8 @@ public GraphQLEngine.Builder builder() { .addSchema(fileBasedSchema(MODULE_SCHEMA_FILE)) .addSchema(fileBasedSchema(PATCH_SCHEMA_FILE)) .addSchema(fileBasedSchema(SETTINGS_SCHEMA_FILE)) - .addSchema(fileBasedSchema(FILES_SCHEMA_FILE)); + .addSchema(fileBasedSchema(FILES_SCHEMA_FILE)) + .addSchema(fileBasedSchema(KNOWLEDGE_SCHEMA_FILE)); for (GmsGraphQLPlugin plugin : this.graphQLPlugins) { List pluginSchemaFiles = plugin.getSchemaFiles(); @@ -2952,6 +2961,20 @@ private void configureDomainResolvers(final RuntimeWiring.Builder builder) { .getUrn()))); } + private void configureDocumentResolvers(final RuntimeWiring.Builder builder) { + // Delegate Knowledge Article wiring to consolidated resolver class + new com.linkedin.datahub.graphql.resolvers.knowledge.DocumentResolvers( + this.documentService, + entityTypes, + documentType, + entityClient, + this.entityService, + this.graphClient, + entityRegistry, + this.timelineService) + .configureResolvers(builder); + } + private void configureFormResolvers(final RuntimeWiring.Builder builder) { builder.type( "FormAssociation", diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngineArgs.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngineArgs.java index 5c618e304d46b5..1049c1a9a0c5a3 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngineArgs.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngineArgs.java @@ -27,6 +27,7 @@ import com.linkedin.metadata.service.BusinessAttributeService; import com.linkedin.metadata.service.DataHubFileService; import com.linkedin.metadata.service.DataProductService; +import com.linkedin.metadata.service.DocumentService; import com.linkedin.metadata.service.ERModelRelationshipService; import com.linkedin.metadata.service.FormService; import com.linkedin.metadata.service.LineageService; @@ -94,6 +95,7 @@ public class GmsGraphQLEngineArgs { ChromeExtensionConfiguration chromeExtensionConfiguration; ConnectionService connectionService; AssertionService assertionService; + DocumentService documentService; EntityVersioningService entityVersioningService; ApplicationService applicationService; PageTemplateService pageTemplateService; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java index 6e33046684a8f0..abb2b49f24592e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/authorization/AuthorizationUtils.java @@ -373,6 +373,82 @@ public static boolean canManageHomePageTemplates(@Nonnull QueryContext context) context.getOperationContext(), PoliciesConfig.MANAGE_HOME_PAGE_TEMPLATES_PRIVILEGE); } + /** + * Returns true if the current user is able to create Knowledge Articles. This is true if the user + * has the 'Create Entity' privilege for Knowledge Articles or 'Manage Knowledge Articles' + * platform privilege. + */ + public static boolean canCreateDocument(@Nonnull QueryContext context) { + final DisjunctivePrivilegeGroup orPrivilegeGroups = + new DisjunctivePrivilegeGroup( + ImmutableList.of( + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.MANAGE_DOCUMENTS_PRIVILEGE.getType())))); + + return AuthUtil.isAuthorized(context.getOperationContext(), orPrivilegeGroups, null); + } + + /** + * Returns true if the current user is able to edit a specific Document. This is true if the user + * has the 'Edit Entity Docs' or 'Edit Entity' metadata privilege on the document, or the 'Manage + * Documents' platform privilege. + */ + public static boolean canEditDocument(@Nonnull Urn documentUrn, @Nonnull QueryContext context) { + final DisjunctivePrivilegeGroup orPrivilegeGroups = + new DisjunctivePrivilegeGroup( + ImmutableList.of( + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.EDIT_ENTITY_DOCS_PRIVILEGE.getType())), + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.EDIT_ENTITY_PRIVILEGE.getType())), + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.MANAGE_DOCUMENTS_PRIVILEGE.getType())))); + + return isAuthorized( + context, documentUrn.getEntityType(), documentUrn.toString(), orPrivilegeGroups); + } + + /** + * Returns true if the current user is able to read a specific Document. This is true if the user + * has the 'Get Entity' metadata privilege on the document or the 'Manage Documents' platform + * privilege. + */ + public static boolean canGetDocument(@Nonnull Urn documentUrn, @Nonnull QueryContext context) { + final DisjunctivePrivilegeGroup orPrivilegeGroups = + new DisjunctivePrivilegeGroup( + ImmutableList.of( + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.VIEW_ENTITY_PAGE_PRIVILEGE.getType())), + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.MANAGE_DOCUMENTS_PRIVILEGE.getType())))); + + return isAuthorized( + context, documentUrn.getEntityType(), documentUrn.toString(), orPrivilegeGroups); + } + + /** + * Returns true if the current user is able to delete a specific Document. This is true if the + * user has the delete entity authorization on the document or the 'Manage Documents' platform + * privilege. + */ + public static boolean canDeleteDocument(@Nonnull Urn documentUrn, @Nonnull QueryContext context) { + // Check if user can delete entity using standard delete authorization + if (AuthUtil.isAuthorizedEntityUrns( + context.getOperationContext(), DELETE, List.of(documentUrn))) { + return true; + } + + // Fallback to document-specific management privilege + final DisjunctivePrivilegeGroup orPrivilegeGroups = + new DisjunctivePrivilegeGroup( + ImmutableList.of( + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.MANAGE_DOCUMENTS_PRIVILEGE.getType())))); + + return isAuthorized( + context, documentUrn.getEntityType(), documentUrn.toString(), orPrivilegeGroups); + } + public static boolean isAuthorized( @Nonnull QueryContext context, @Nonnull String resourceType, diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/CreateDocumentResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/CreateDocumentResolver.java new file mode 100644 index 00000000000000..d0504a7a82adda --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/CreateDocumentResolver.java @@ -0,0 +1,170 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +import com.linkedin.common.Owner; +import com.linkedin.common.OwnershipType; +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.concurrency.GraphQLConcurrencyUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.CreateDocumentInput; +import com.linkedin.datahub.graphql.generated.OwnerInput; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolver used for creating a new Document on DataHub. Requires the CREATE_ENTITY privilege for + * Documents or MANAGE_DOCUMENTS privilege. + */ +@Slf4j +@RequiredArgsConstructor +public class CreateDocumentResolver implements DataFetcher> { + + private final DocumentService _documentService; + private final EntityService _entityService; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + + final QueryContext context = environment.getContext(); + final CreateDocumentInput input = + bindArgument(environment.getArgument("input"), CreateDocumentInput.class); + + return GraphQLConcurrencyUtils.supplyAsync( + () -> { + if (!AuthorizationUtils.canCreateDocument(context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + try { + // Extract content text + final String content = input.getContents().getText(); + + // Extract related URNs + final Urn parentDocumentUrn = + input.getParentDocument() != null + ? UrnUtils.getUrn(input.getParentDocument()) + : null; + final List relatedAssetUrns = + input.getRelatedAssets() != null + ? input.getRelatedAssets().stream() + .map(UrnUtils::getUrn) + .collect(Collectors.toList()) + : null; + final List relatedDocumentUrns = + input.getRelatedDocuments() != null + ? input.getRelatedDocuments().stream() + .map(UrnUtils::getUrn) + .collect(Collectors.toList()) + : null; + + // Map GraphQL state enum to PDL enum if provided. If draftFor is provided, force + // UNPUBLISHED for the draft document. + com.linkedin.knowledge.DocumentState pdlState = + input.getState() != null + ? com.linkedin.knowledge.DocumentState.valueOf(input.getState().name()) + : null; + final String draftForUrn = input.getDraftFor(); + if (draftForUrn != null) { + pdlState = com.linkedin.knowledge.DocumentState.UNPUBLISHED; + } + + // Automatically create source with NATIVE type - users cannot set this via API + // (reserved for ingestion from external systems) + final com.linkedin.knowledge.DocumentSource source = + new com.linkedin.knowledge.DocumentSource(); + source.setSourceType(com.linkedin.knowledge.DocumentSourceType.NATIVE); + + // Convert single subType to list for subTypes aspect + final List subTypes = + input.getSubType() != null + ? java.util.Collections.singletonList(input.getSubType()) + : null; + + // Create document using service (draftFor parameter will handle draft logic) + final Urn draftForUrnParsed = draftForUrn != null ? UrnUtils.getUrn(draftForUrn) : null; + final Urn documentUrn = + _documentService.createDocument( + context.getOperationContext(), + input.getId(), + subTypes, + input.getTitle(), + source, + pdlState, + content, + parentDocumentUrn, + relatedAssetUrns, + relatedDocumentUrns, + draftForUrnParsed, + UrnUtils.getUrn(context.getActorUrn())); + + // Set ownership + final Urn actorUrn = UrnUtils.getUrn(context.getActorUrn()); + if (input.getOwners() != null && !input.getOwners().isEmpty()) { + // Use provided owners + final List owners = mapOwnerInputsToOwners(input.getOwners()); + _documentService.setDocumentOwnership( + context.getOperationContext(), documentUrn, owners, actorUrn); + } else { + // Default to adding the creator as owner + final Owner creatorOwner = new Owner(); + creatorOwner.setOwner(actorUrn); + creatorOwner.setType(OwnershipType.TECHNICAL_OWNER); + _documentService.setDocumentOwnership( + context.getOperationContext(), + documentUrn, + java.util.Collections.singletonList(creatorOwner), + actorUrn); + } + + return documentUrn.toString(); + } catch (Exception e) { + log.error( + "Failed to create Document with id: {}, subType: {}: {}", + input.getId(), + input.getSubType(), + e.getMessage()); + throw new RuntimeException( + String.format("Failed to create Document: %s", e.getMessage()), e); + } + }, + this.getClass().getSimpleName(), + "get"); + } + + /** Maps GraphQL OwnerInputs to PDL Owner objects. */ + private List mapOwnerInputsToOwners(List ownerInputs) { + List owners = new ArrayList<>(); + for (OwnerInput ownerInput : ownerInputs) { + Owner owner = new Owner(); + owner.setOwner(UrnUtils.getUrn(ownerInput.getOwnerUrn())); + + // Map ownership type + if (ownerInput.getOwnershipTypeUrn() != null) { + // Custom ownership type URN + owner.setTypeUrn(UrnUtils.getUrn(ownerInput.getOwnershipTypeUrn())); + } else if (ownerInput.getType() != null) { + // Standard ownership type enum + owner.setType(OwnershipType.valueOf(ownerInput.getType().name())); + } else { + // Default to NONE + owner.setType(OwnershipType.NONE); + } + + owners.add(owner); + } + return owners; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/DeleteDocumentResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/DeleteDocumentResolver.java new file mode 100644 index 00000000000000..34c7dd1ea9dc12 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/DeleteDocumentResolver.java @@ -0,0 +1,55 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +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.concurrency.GraphQLConcurrencyUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolver responsible for hard deleting a particular Document. Requires the GET_ENTITY metadata + * privilege on the document or the MANAGE_DOCUMENTS platform privilege. + */ +@Slf4j +@RequiredArgsConstructor +public class DeleteDocumentResolver implements DataFetcher> { + + private final DocumentService _documentService; + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) + throws Exception { + final QueryContext context = environment.getContext(); + final String documentUrnString = environment.getArgument("urn"); + final Urn documentUrn = UrnUtils.getUrn(documentUrnString); + + return GraphQLConcurrencyUtils.supplyAsync( + () -> { + if (!AuthorizationUtils.canDeleteDocument(documentUrn, context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + try { + // Delete using service + _documentService.deleteDocument(context.getOperationContext(), documentUrn); + + return true; + } catch (Exception e) { + log.error( + "Failed to delete Document with URN {}: {}", documentUrnString, e.getMessage()); + throw new RuntimeException( + String.format("Failed to delete Document with urn %s", documentUrnString), e); + } + }, + this.getClass().getSimpleName(), + "get"); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentChangeHistoryResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentChangeHistoryResolver.java new file mode 100644 index 00000000000000..df766d36d0d832 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentChangeHistoryResolver.java @@ -0,0 +1,237 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils; +import com.linkedin.datahub.graphql.generated.CorpUser; +import com.linkedin.datahub.graphql.generated.Document; +import com.linkedin.datahub.graphql.generated.DocumentChange; +import com.linkedin.datahub.graphql.generated.DocumentChangeType; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.StringMapEntry; +import com.linkedin.metadata.timeline.TimelineService; +import com.linkedin.metadata.timeline.data.ChangeCategory; +import com.linkedin.metadata.timeline.data.ChangeEvent; +import com.linkedin.metadata.timeline.data.ChangeOperation; +import com.linkedin.metadata.timeline.data.ChangeTransaction; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolver for Document.changeHistory field. Fetches change history for a document using the + * Timeline Service and converts it to a simple, document-native format. + */ +@Slf4j +@RequiredArgsConstructor +public class DocumentChangeHistoryResolver + implements DataFetcher>> { + + private final TimelineService _timelineService; + private static final long DEFAULT_LOOKBACK_MILLIS = 30L * 24 * 60 * 60 * 1000; // 30 days + private static final int DEFAULT_LIMIT = 50; + + @Override + public CompletableFuture> get(DataFetchingEnvironment environment) + throws Exception { + final QueryContext context = environment.getContext(); + final Document source = environment.getSource(); + final Urn documentUrn = UrnUtils.getUrn(source.getUrn()); + + // Parse arguments + final Long startTimeMillis = environment.getArgument("startTimeMillis"); + final Long endTimeMillis = environment.getArgument("endTimeMillis"); + final Integer limit = environment.getArgument("limit"); + + return GraphQLConcurrencyUtils.supplyAsync( + () -> { + try { + // Calculate time range + long endTime = endTimeMillis != null ? endTimeMillis : System.currentTimeMillis(); + long startTime = + startTimeMillis != null ? startTimeMillis : (endTime - DEFAULT_LOOKBACK_MILLIS); + int maxResults = limit != null ? limit : DEFAULT_LIMIT; + + // Fetch all relevant change categories for documents + Set categories = getAllDocumentChangeCategories(); + + // Get timeline from TimelineService + List transactions = + _timelineService.getTimeline( + documentUrn, + categories, + startTime, + endTime, + null, // startVersionStamp + null, // endVersionStamp + false); // rawDiffsRequested + + // Convert to document-native format and flatten + List changes = new ArrayList<>(); + for (ChangeTransaction transaction : transactions) { + if (transaction.getChangeEvents() != null) { + for (ChangeEvent event : transaction.getChangeEvents()) { + DocumentChange change = convertToDocumentChange(event); + if (change != null) { + changes.add(change); + } + } + } + } + + // Sort by timestamp descending (most recent first) and limit + // Handle null timestamps by treating them as 0 + changes.sort( + (a, b) -> { + Long aTime = a.getTimestamp() != null ? a.getTimestamp() : 0L; + Long bTime = b.getTimestamp() != null ? b.getTimestamp() : 0L; + return Long.compare(bTime, aTime); + }); + if (changes.size() > maxResults) { + changes = changes.subList(0, maxResults); + } + + return changes; + } catch (Exception e) { + log.error( + "Failed to fetch change history for document {}: {}", + documentUrn, + e.getMessage(), + e); + throw new RuntimeException("Failed to fetch change history: " + e.getMessage(), e); + } + }, + this.getClass().getSimpleName(), + "get"); + } + + /** + * Get all change categories relevant to documents. This includes documentation changes, lifecycle + * events, and relationship changes (using TAG as a proxy). + */ + private Set getAllDocumentChangeCategories() { + Set categories = new HashSet<>(); + categories.add(ChangeCategory.DOCUMENTATION); // content/title changes + categories.add(ChangeCategory.LIFECYCLE); // creation, state changes + categories.add(ChangeCategory.TAG); // parent & related entity changes (using TAG as proxy) + return categories; + } + + /** + * Convert a Timeline ChangeEvent to a document-native DocumentChange. This abstracts away the + * Timeline Service implementation details and provides a clean, simple interface for document + * changes. + */ + @Nullable + private DocumentChange convertToDocumentChange(ChangeEvent event) { + if (event == null) { + return null; + } + + DocumentChange change = new DocumentChange(); + + // Map change type + DocumentChangeType changeType = mapToDocumentChangeType(event); + if (changeType == null) { + return null; // Skip unmapped events + } + change.setChangeType(changeType); + + // Set description + change.setDescription( + event.getDescription() != null ? event.getDescription() : "Change occurred"); + + // Set timestamp + change.setTimestamp( + event.getAuditStamp() != null + ? event.getAuditStamp().getTime() + : System.currentTimeMillis()); + + // Set actor (optional) + if (event.getAuditStamp() != null && event.getAuditStamp().hasActor()) { + CorpUser actor = new CorpUser(); + actor.setUrn(event.getAuditStamp().getActor().toString()); + actor.setType(EntityType.CORP_USER); + change.setActor(actor); + } + + // Set details (optional parameters) + if (event.getParameters() != null && !event.getParameters().isEmpty()) { + List details = new ArrayList<>(); + for (Map.Entry entry : event.getParameters().entrySet()) { + StringMapEntry mapEntry = new StringMapEntry(); + mapEntry.setKey(entry.getKey()); + mapEntry.setValue(entry.getValue() != null ? entry.getValue().toString() : ""); + details.add(mapEntry); + } + change.setDetails(details); + } + + return change; + } + + /** + * Map Timeline ChangeEvent to document-specific DocumentChangeType. This provides a clean + * abstraction layer that can be swapped out for event-based tracking in the future. + */ + @Nullable + private DocumentChangeType mapToDocumentChangeType(ChangeEvent event) { + ChangeCategory category = event.getCategory(); + ChangeOperation operation = event.getOperation(); + + if (category == null || operation == null) { + return null; + } + + // Creation events + if (operation == ChangeOperation.CREATE) { + return DocumentChangeType.CREATED; + } + + // Map based on category and description patterns + switch (category) { + case DOCUMENTATION: + // Content or title changes + if (event.getDescription() != null && event.getDescription().contains("title")) { + return DocumentChangeType.CONTENT_MODIFIED; + } + return DocumentChangeType.CONTENT_MODIFIED; + + case LIFECYCLE: + // State changes or deletion + if (operation == ChangeOperation.REMOVE) { + return DocumentChangeType.DELETED; + } + if (event.getDescription() != null && event.getDescription().contains("state")) { + return DocumentChangeType.STATE_CHANGED; + } + return DocumentChangeType.CREATED; + + case TAG: + // Using TAG as proxy for parent and related entity changes + if (event.getDescription() != null) { + String desc = event.getDescription().toLowerCase(); + if (desc.contains("parent")) { + return DocumentChangeType.PARENT_CHANGED; + } else if (desc.contains("asset")) { + return DocumentChangeType.RELATED_ASSETS_CHANGED; + } else if (desc.contains("document")) { + return DocumentChangeType.RELATED_DOCUMENTS_CHANGED; + } + } + return null; // Skip unmapped TAG events + + default: + return null; + } + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentDraftsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentDraftsResolver.java new file mode 100644 index 00000000000000..b79f233ec33b3a --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentDraftsResolver.java @@ -0,0 +1,55 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils; +import com.linkedin.datahub.graphql.generated.Document; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class DocumentDraftsResolver implements DataFetcher>> { + + // TODO: This is a temporary limit for V1, if we need to support more drafts, we need to add + // pagination to this resolver. + private static final int MAX_DRAFTS = 1000; + private final DocumentService _documentService; + + @Override + public CompletableFuture> get(DataFetchingEnvironment environment) + throws Exception { + final QueryContext context = environment.getContext(); + final OperationContext opContext = context.getOperationContext(); + final Document source = environment.getSource(); + final Urn publishedUrn = UrnUtils.getUrn(source.getUrn()); + + return GraphQLConcurrencyUtils.supplyAsync( + () -> { + try { + var searchResult = + _documentService.getDraftDocuments(opContext, publishedUrn, 0, MAX_DRAFTS); + return searchResult.getEntities().stream() + .map( + entity -> { + Document doc = new Document(); + doc.setUrn(entity.getEntity().toString()); + // Type is resolved downstream when hydrated; set as DOCUMENT for consistency + doc.setType(com.linkedin.datahub.graphql.generated.EntityType.DOCUMENT); + return doc; + }) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new RuntimeException("Failed to fetch draft documents: " + e.getMessage(), e); + } + }, + this.getClass().getSimpleName(), + "get"); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentResolvers.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentResolvers.java new file mode 100644 index 00000000000000..1d526a225d56ed --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentResolvers.java @@ -0,0 +1,171 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import com.linkedin.datahub.graphql.resolvers.load.EntityRelationshipsResultResolver; +import com.linkedin.datahub.graphql.resolvers.load.EntityTypeResolver; +import com.linkedin.datahub.graphql.resolvers.load.LoadableTypeResolver; +import com.linkedin.datahub.graphql.types.knowledge.DocumentType; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.service.DocumentService; +import com.linkedin.metadata.timeline.TimelineService; +import graphql.schema.idl.RuntimeWiring; +import javax.annotation.Nonnull; + +/** Configures resolvers for Document query, mutation, and type wiring. */ +public class DocumentResolvers { + + private static final String QUERY_TYPE = "Query"; + private static final String MUTATION_TYPE = "Mutation"; + + private final DocumentService documentService; + private final java.util.List> entityTypes; + private final DocumentType documentType; + private final EntityClient entityClient; + private final EntityService entityService; + private final com.linkedin.metadata.graph.GraphClient graphClient; + private final EntityRegistry entityRegistry; + private final TimelineService timelineService; + + public DocumentResolvers( + @Nonnull DocumentService documentService, + @Nonnull java.util.List> entityTypes, + @Nonnull DocumentType documentType, + @Nonnull EntityClient entityClient, + @Nonnull EntityService entityService, + @Nonnull com.linkedin.metadata.graph.GraphClient graphClient, + @Nonnull EntityRegistry entityRegistry, + @Nonnull TimelineService timelineService) { + this.documentService = documentService; + this.entityTypes = entityTypes; + this.documentType = documentType; + this.entityClient = entityClient; + this.entityService = entityService; + this.graphClient = graphClient; + this.entityRegistry = entityRegistry; + this.timelineService = timelineService; + } + + public void configureResolvers(final RuntimeWiring.Builder builder) { + // Query resolvers + builder.type( + QUERY_TYPE, + typeWiring -> + typeWiring + .dataFetcher( + "document", + new com.linkedin.datahub.graphql.resolvers.load.LoadableTypeResolver<>( + documentType, (env) -> env.getArgument("urn"))) + .dataFetcher( + "searchDocuments", + new com.linkedin.datahub.graphql.resolvers.knowledge.SearchDocumentsResolver( + documentService))); + + // Mutation resolvers + builder.type( + MUTATION_TYPE, + typeWiring -> + typeWiring + .dataFetcher( + "createDocument", + new com.linkedin.datahub.graphql.resolvers.knowledge.CreateDocumentResolver( + documentService, entityService)) + .dataFetcher( + "updateDocumentContents", + new com.linkedin.datahub.graphql.resolvers.knowledge + .UpdateDocumentContentsResolver(documentService)) + .dataFetcher( + "updateDocumentRelatedEntities", + new com.linkedin.datahub.graphql.resolvers.knowledge + .UpdateDocumentRelatedEntitiesResolver(documentService)) + .dataFetcher( + "moveDocument", + new com.linkedin.datahub.graphql.resolvers.knowledge.MoveDocumentResolver( + documentService)) + .dataFetcher( + "deleteDocument", + new com.linkedin.datahub.graphql.resolvers.knowledge.DeleteDocumentResolver( + documentService)) + .dataFetcher( + "updateDocumentStatus", + new com.linkedin.datahub.graphql.resolvers.knowledge + .UpdateDocumentStatusResolver(documentService)) + .dataFetcher( + "mergeDraft", + new com.linkedin.datahub.graphql.resolvers.knowledge.MergeDraftResolver( + documentService, entityService))); + + // Type wiring for Document root + builder.type( + "Document", + typeWiring -> + typeWiring + .dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)) + .dataFetcher( + "aspects", + new com.linkedin.datahub.graphql.WeaklyTypedAspectsResolver( + entityClient, entityRegistry)) + .dataFetcher( + "drafts", + new com.linkedin.datahub.graphql.resolvers.knowledge.DocumentDraftsResolver( + documentService)) + .dataFetcher( + "changeHistory", + new com.linkedin.datahub.graphql.resolvers.knowledge + .DocumentChangeHistoryResolver(timelineService))); + + // Resolve DocumentInfo.relatedAssets[].asset -> Entity (resolved) + builder.type( + "DocumentRelatedAsset", + typeWiring -> + typeWiring.dataFetcher( + "asset", + new EntityTypeResolver( + entityTypes, + (env) -> + ((com.linkedin.datahub.graphql.generated.DocumentRelatedAsset) + env.getSource()) + .getAsset()))); + + // Resolve DocumentInfo.relatedArticles[].document -> Document (resolved) + builder.type( + "DocumentRelatedDocument", + typeWiring -> + typeWiring.dataFetcher( + "document", + new LoadableTypeResolver<>( + documentType, + (env) -> + ((com.linkedin.datahub.graphql.generated.DocumentRelatedDocument) + env.getSource()) + .getDocument() + .getUrn()))); + + // Resolve DocumentInfo.parentArticle.document -> Document (resolved) + builder.type( + "DocumentParentDocument", + typeWiring -> + typeWiring.dataFetcher( + "document", + new LoadableTypeResolver<>( + documentType, + (env) -> + ((com.linkedin.datahub.graphql.generated.DocumentParentDocument) + env.getSource()) + .getDocument() + .getUrn()))); + + // Resolve DocumentInfo.draftOf.document -> Document (resolved) + builder.type( + "DocumentDraftOf", + typeWiring -> + typeWiring.dataFetcher( + "document", + new LoadableTypeResolver<>( + documentType, + (env) -> + ((com.linkedin.datahub.graphql.generated.DocumentDraftOf) env.getSource()) + .getDocument() + .getUrn()))); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/MergeDraftResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/MergeDraftResolver.java new file mode 100644 index 00000000000000..139186bb7e29e5 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/MergeDraftResolver.java @@ -0,0 +1,57 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +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.concurrency.GraphQLConcurrencyUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.MergeDraftInput; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class MergeDraftResolver implements DataFetcher> { + + private final DocumentService _documentService; + private final EntityService _entityService; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + final QueryContext context = environment.getContext(); + final MergeDraftInput input = + bindArgument(environment.getArgument("input"), MergeDraftInput.class); + final Urn draftUrn = UrnUtils.getUrn(input.getDraftUrn()); + + return GraphQLConcurrencyUtils.supplyAsync( + () -> { + if (!AuthorizationUtils.canEditDocument(draftUrn, context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + try { + final OperationContext opContext = context.getOperationContext(); + final boolean deleteDraft = input.getDeleteDraft() == null || input.getDeleteDraft(); + final Urn actorUrn = UrnUtils.getUrn(context.getActorUrn()); + + _documentService.mergeDraftIntoParent(opContext, draftUrn, deleteDraft, actorUrn); + return true; + } catch (Exception e) { + log.error("Failed to merge draft {}: {}", input.getDraftUrn(), e.toString()); + throw new RuntimeException("Failed to merge draft: " + e.getMessage(), e); + } + }, + this.getClass().getSimpleName(), + "get"); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/MoveDocumentResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/MoveDocumentResolver.java new file mode 100644 index 00000000000000..f77b6863f049b3 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/MoveDocumentResolver.java @@ -0,0 +1,69 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +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.concurrency.GraphQLConcurrencyUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.MoveDocumentInput; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolver used for moving a Document to a different parent (or to root level if no parent is + * specified). Requires the EDIT_ENTITY_DOCS or EDIT_ENTITY metadata privilege on the document, or + * MANAGE_DOCUMENTS platform privilege. + */ +@Slf4j +@RequiredArgsConstructor +public class MoveDocumentResolver implements DataFetcher> { + + private final DocumentService _documentService; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + + final QueryContext context = environment.getContext(); + final MoveDocumentInput input = + bindArgument(environment.getArgument("input"), MoveDocumentInput.class); + + final Urn documentUrn = UrnUtils.getUrn(input.getUrn()); + + return GraphQLConcurrencyUtils.supplyAsync( + () -> { + if (!AuthorizationUtils.canEditDocument(documentUrn, context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + try { + final Urn newParentUrn = + input.getParentDocument() != null + ? UrnUtils.getUrn(input.getParentDocument()) + : null; + + // Move using service + _documentService.moveDocument( + context.getOperationContext(), + documentUrn, + newParentUrn, + UrnUtils.getUrn(context.getActorUrn())); + + return true; + } catch (Exception e) { + log.error("Failed to move Document with URN {}: {}", input.getUrn(), e.getMessage()); + throw new RuntimeException( + String.format("Failed to move Document: %s", e.getMessage()), e); + } + }, + this.getClass().getSimpleName(), + "get"); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/SearchDocumentsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/SearchDocumentsResolver.java new file mode 100644 index 00000000000000..1d27eb98005aab --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/SearchDocumentsResolver.java @@ -0,0 +1,194 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils; +import com.linkedin.datahub.graphql.generated.Document; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.SearchDocumentsInput; +import com.linkedin.datahub.graphql.generated.SearchDocumentsResult; +import com.linkedin.datahub.graphql.resolvers.ResolverUtils; +import com.linkedin.datahub.graphql.types.mappers.MapperUtils; +import com.linkedin.metadata.query.filter.Condition; +import com.linkedin.metadata.query.filter.Criterion; +import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.metadata.search.SearchEntity; +import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.service.DocumentService; +import com.linkedin.metadata.utils.CriterionUtils; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolver used for searching Documents with hybrid semantic search and advanced filtering support. + * By default, only PUBLISHED documents are returned unless specific states are requested. + */ +@Slf4j +@RequiredArgsConstructor +public class SearchDocumentsResolver + implements DataFetcher> { + + private static final Integer DEFAULT_START = 0; + private static final Integer DEFAULT_COUNT = 20; + private static final String DEFAULT_QUERY = "*"; + + private final DocumentService _documentService; + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) + throws Exception { + + final QueryContext context = environment.getContext(); + + return GraphQLConcurrencyUtils.supplyAsync( + () -> { + final SearchDocumentsInput input = + bindArgument(environment.getArgument("input"), SearchDocumentsInput.class); + final Integer start = input.getStart() == null ? DEFAULT_START : input.getStart(); + final Integer count = input.getCount() == null ? DEFAULT_COUNT : input.getCount(); + final String query = input.getQuery() == null ? DEFAULT_QUERY : input.getQuery(); + + try { + // Build filter combining all the ANDed conditions + Filter filter = buildCombinedFilter(input); + + // No need to manipulate context - search method accepts OperationContext with search + // flags + + // Search using service + final SearchResult gmsResult; + try { + gmsResult = + _documentService.searchDocuments( + context.getOperationContext(), query, filter, null, start, count); + } catch (Exception e) { + throw new RuntimeException("Failed to search documents", e); + } + + // Build the result + final SearchDocumentsResult result = new SearchDocumentsResult(); + result.setStart(gmsResult.getFrom()); + result.setCount(gmsResult.getPageSize()); + result.setTotal(gmsResult.getNumEntities()); + result.setDocuments( + mapUnresolvedArticles( + gmsResult.getEntities().stream() + .map(SearchEntity::getEntity) + .collect(Collectors.toList()))); + + // Map facets + if (gmsResult.getMetadata() != null + && gmsResult.getMetadata().getAggregations() != null) { + result.setFacets( + gmsResult.getMetadata().getAggregations().stream() + .map(facet -> MapperUtils.mapFacet(context, facet)) + .collect(Collectors.toList())); + } else { + result.setFacets(Collections.emptyList()); + } + + return result; + } catch (Exception e) { + log.error("Failed to search documents: {}", e.getMessage()); + throw new RuntimeException("Failed to search documents", e); + } + }, + this.getClass().getSimpleName(), + "get"); + } + + /** Builds a combined filter that ANDs together all provided filters. */ + private Filter buildCombinedFilter(SearchDocumentsInput input) { + List criteria = new ArrayList<>(); + + // Add parent document filter if provided + if (input.getParentDocument() != null) { + criteria.add( + CriterionUtils.buildCriterion( + "parentArticle", Condition.EQUAL, input.getParentDocument())); + } + + // Add types filter if provided (now using subTypes aspect) + if (input.getTypes() != null && !input.getTypes().isEmpty()) { + criteria.add(CriterionUtils.buildCriterion("subTypes", Condition.EQUAL, input.getTypes())); + } + + // Add domains filter if provided + if (input.getDomains() != null && !input.getDomains().isEmpty()) { + criteria.add(CriterionUtils.buildCriterion("domains", Condition.EQUAL, input.getDomains())); + } + + // Add states filter - defaults to PUBLISHED if not provided + if (input.getStates() == null || input.getStates().isEmpty()) { + // Default to PUBLISHED only + criteria.add(CriterionUtils.buildCriterion("state", Condition.EQUAL, "PUBLISHED")); + } else { + // Convert DocumentState enums to strings + List stateStrings = + input.getStates().stream().map(state -> state.toString()).collect(Collectors.toList()); + criteria.add(CriterionUtils.buildCriterion("state", Condition.EQUAL, stateStrings)); + } + + // Exclude documents that are drafts by default, unless explicitly requested + if (input.getIncludeDrafts() == null || !input.getIncludeDrafts()) { + Criterion notDraftCriterion = new Criterion(); + notDraftCriterion.setField("draftOf"); + notDraftCriterion.setCondition(Condition.IS_NULL); + criteria.add(notDraftCriterion); + } + + // Add custom facet filters if provided - convert to AndFilterInput format + if (input.getFilters() != null && !input.getFilters().isEmpty()) { + final List orFilters = + new ArrayList<>(); + final com.linkedin.datahub.graphql.generated.AndFilterInput andFilter = + new com.linkedin.datahub.graphql.generated.AndFilterInput(); + andFilter.setAnd(input.getFilters()); + orFilters.add(andFilter); + Filter additionalFilter = ResolverUtils.buildFilter(null, orFilters); + if (additionalFilter != null && additionalFilter.getOr() != null) { + additionalFilter + .getOr() + .forEach( + conj -> { + if (conj.getAnd() != null) { + criteria.addAll(conj.getAnd()); + } + }); + } + } + + // If no filters, return null (search everything) + if (criteria.isEmpty()) { + return null; + } + + // Create a conjunctive filter (AND all criteria together) + return new com.linkedin.metadata.query.filter.Filter() + .setOr( + new com.linkedin.metadata.query.filter.ConjunctiveCriterionArray( + new com.linkedin.metadata.query.filter.ConjunctiveCriterion() + .setAnd(new com.linkedin.metadata.query.filter.CriterionArray(criteria)))); + } + + /** Maps URNs to unresolved Document objects for batch loading. */ + private List mapUnresolvedArticles(final List entityUrns) { + final List results = new ArrayList<>(); + for (final Urn urn : entityUrns) { + final Document unresolvedArticle = new Document(); + unresolvedArticle.setUrn(urn.toString()); + unresolvedArticle.setType(EntityType.DOCUMENT); + results.add(unresolvedArticle); + } + return results; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentContentsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentContentsResolver.java new file mode 100644 index 00000000000000..c82bf402f0a472 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentContentsResolver.java @@ -0,0 +1,77 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +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.concurrency.GraphQLConcurrencyUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.UpdateDocumentContentsInput; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolver used for updating the contents of a Document on DataHub. Requires the EDIT_ENTITY_DOCS + * or EDIT_ENTITY metadata privilege on the document, or MANAGE_DOCUMENTS platform privilege. + */ +@Slf4j +@RequiredArgsConstructor +public class UpdateDocumentContentsResolver implements DataFetcher> { + + private final DocumentService _documentService; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + + final QueryContext context = environment.getContext(); + final UpdateDocumentContentsInput input = + bindArgument(environment.getArgument("input"), UpdateDocumentContentsInput.class); + + final Urn documentUrn = UrnUtils.getUrn(input.getUrn()); + + return GraphQLConcurrencyUtils.supplyAsync( + () -> { + if (!AuthorizationUtils.canEditDocument(documentUrn, context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + try { + // Extract content text + final String content = input.getContents().getText(); + + // Extract subType and convert to list if provided + final java.util.List subTypes = + input.getSubType() != null + ? java.util.Collections.singletonList(input.getSubType()) + : null; + + // Update using service + _documentService.updateDocumentContents( + context.getOperationContext(), + documentUrn, + content, + input.getTitle(), + subTypes, + UrnUtils.getUrn(context.getActorUrn())); + + return true; + } catch (Exception e) { + log.error( + "Failed to update contents for Document with URN {}: {}", + input.getUrn(), + e.getMessage()); + throw new RuntimeException( + String.format("Failed to update Document contents: %s", e.getMessage()), e); + } + }, + this.getClass().getSimpleName(), + "get"); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentRelatedEntitiesResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentRelatedEntitiesResolver.java new file mode 100644 index 00000000000000..166fffac0f4bcb --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentRelatedEntitiesResolver.java @@ -0,0 +1,86 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +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.concurrency.GraphQLConcurrencyUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.UpdateDocumentRelatedEntitiesInput; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolver used for updating the related entities (assets and documents) of a Document on DataHub. + * Requires the EDIT_ENTITY_DOCS or EDIT_ENTITY metadata privilege on the document, or + * MANAGE_DOCUMENTS platform privilege. + */ +@Slf4j +@RequiredArgsConstructor +public class UpdateDocumentRelatedEntitiesResolver + implements DataFetcher> { + + private final DocumentService _documentService; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + + final QueryContext context = environment.getContext(); + final UpdateDocumentRelatedEntitiesInput input = + bindArgument(environment.getArgument("input"), UpdateDocumentRelatedEntitiesInput.class); + + final Urn documentUrn = UrnUtils.getUrn(input.getUrn()); + + return GraphQLConcurrencyUtils.supplyAsync( + () -> { + if (!AuthorizationUtils.canEditDocument(documentUrn, context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + try { + // Extract URNs + final List relatedAssetUrns = + input.getRelatedAssets() != null + ? input.getRelatedAssets().stream() + .map(UrnUtils::getUrn) + .collect(Collectors.toList()) + : null; + + final List relatedDocumentUrns = + input.getRelatedDocuments() != null + ? input.getRelatedDocuments().stream() + .map(UrnUtils::getUrn) + .collect(Collectors.toList()) + : null; + + // Update using service + _documentService.updateDocumentRelatedEntities( + context.getOperationContext(), + documentUrn, + relatedAssetUrns, + relatedDocumentUrns, + UrnUtils.getUrn(context.getActorUrn())); + + return true; + } catch (Exception e) { + log.error( + "Failed to update related entities for Document with URN {}: {}", + input.getUrn(), + e.getMessage()); + throw new RuntimeException( + String.format("Failed to update Document related entities: %s", e.getMessage()), e); + } + }, + this.getClass().getSimpleName(), + "get"); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentStatusResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentStatusResolver.java new file mode 100644 index 00000000000000..e7b59dbe23e52a --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentStatusResolver.java @@ -0,0 +1,70 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; + +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.concurrency.GraphQLConcurrencyUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.UpdateDocumentStatusInput; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolver used for updating the status of a Document on DataHub. Requires the EDIT_ENTITY_DOCS or + * EDIT_ENTITY privilege for the document or MANAGE_DOCUMENTS privilege. + */ +@Slf4j +@RequiredArgsConstructor +public class UpdateDocumentStatusResolver implements DataFetcher> { + + private final DocumentService _documentService; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + + final QueryContext context = environment.getContext(); + final UpdateDocumentStatusInput input = + bindArgument(environment.getArgument("input"), UpdateDocumentStatusInput.class); + + final Urn documentUrn = UrnUtils.getUrn(input.getUrn()); + + return GraphQLConcurrencyUtils.supplyAsync( + () -> { + // Use the same authorization check as update operations - need to edit the document + if (!AuthorizationUtils.canEditDocument(documentUrn, context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + try { + // Map GraphQL enum to PDL enum + final com.linkedin.knowledge.DocumentState pdlState = + com.linkedin.knowledge.DocumentState.valueOf(input.getState().name()); + + _documentService.updateDocumentStatus( + context.getOperationContext(), + documentUrn, + pdlState, + UrnUtils.getUrn(context.getActorUrn())); + + return true; + } catch (Exception e) { + log.error( + "Failed to update status for document {}. Error: {}", + input.getUrn(), + e.getMessage()); + throw new RuntimeException( + String.format("Failed to update status for document %s", input.getUrn()), e); + } + }, + this.getClass().getSimpleName(), + "get"); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/UrnToEntityMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/UrnToEntityMapper.java index d31285de4edfb9..c462e0b17734a7 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/UrnToEntityMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/common/mappers/UrnToEntityMapper.java @@ -24,6 +24,7 @@ import com.linkedin.datahub.graphql.generated.DataProcessInstance; import com.linkedin.datahub.graphql.generated.DataProduct; import com.linkedin.datahub.graphql.generated.Dataset; +import com.linkedin.datahub.graphql.generated.Document; import com.linkedin.datahub.graphql.generated.Domain; import com.linkedin.datahub.graphql.generated.ERModelRelationship; import com.linkedin.datahub.graphql.generated.Entity; @@ -267,6 +268,11 @@ public Entity apply(@Nullable QueryContext context, Urn input) { ((DataHubPageModule) partialEntity).setUrn(input.toString()); ((DataHubPageModule) partialEntity).setType(EntityType.DATAHUB_PAGE_MODULE); } + if (input.getEntityType().equals(DOCUMENT_ENTITY_NAME)) { + partialEntity = new Document(); + ((Document) partialEntity).setUrn(input.toString()); + ((Document) partialEntity).setType(EntityType.DOCUMENT); + } return partialEntity; } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeMapper.java index 5c2c86a0e3bfe8..1f90ab0d5633ef 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeMapper.java @@ -60,6 +60,7 @@ public class EntityTypeMapper { .put(EntityType.BUSINESS_ATTRIBUTE, Constants.BUSINESS_ATTRIBUTE_ENTITY_NAME) .put(EntityType.DATA_CONTRACT, Constants.DATA_CONTRACT_ENTITY_NAME) .put(EntityType.APPLICATION, Constants.APPLICATION_ENTITY_NAME) + .put(EntityType.DOCUMENT, Constants.DOCUMENT_ENTITY_NAME) .build(); private static final Map ENTITY_NAME_TO_TYPE = diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeUrnMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeUrnMapper.java index 32f4ca8d658e1c..d9fd667ef39e0c 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeUrnMapper.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/entitytype/EntityTypeUrnMapper.java @@ -79,6 +79,7 @@ public class EntityTypeUrnMapper { Constants.BUSINESS_ATTRIBUTE_ENTITY_NAME, "urn:li:entityType:datahub.businessAttribute") .put(Constants.APPLICATION_ENTITY_NAME, "urn:li:entityType:datahub.application") + .put(Constants.DOCUMENT_ENTITY_NAME, "urn:li:entityType:datahub.document") .build(); private static final Map ENTITY_TYPE_URN_TO_NAME = diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/knowledge/DocumentMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/knowledge/DocumentMapper.java new file mode 100644 index 00000000000000..f21e97a81d0333 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/knowledge/DocumentMapper.java @@ -0,0 +1,272 @@ +package com.linkedin.datahub.graphql.types.knowledge; + +import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.canView; + +import com.linkedin.common.BrowsePathsV2; +import com.linkedin.common.DataPlatformInstance; +import com.linkedin.common.GlobalTags; +import com.linkedin.common.GlossaryTerms; +import com.linkedin.common.Ownership; +import com.linkedin.common.Status; +import com.linkedin.common.SubTypes; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.Document; +import com.linkedin.datahub.graphql.generated.DocumentContent; +import com.linkedin.datahub.graphql.generated.DocumentDraftOf; +import com.linkedin.datahub.graphql.generated.DocumentInfo; +import com.linkedin.datahub.graphql.generated.DocumentParentDocument; +import com.linkedin.datahub.graphql.generated.DocumentRelatedAsset; +import com.linkedin.datahub.graphql.generated.DocumentRelatedDocument; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.types.common.mappers.AuditStampMapper; +import com.linkedin.datahub.graphql.types.common.mappers.BrowsePathsV2Mapper; +import com.linkedin.datahub.graphql.types.common.mappers.CustomPropertiesMapper; +import com.linkedin.datahub.graphql.types.common.mappers.DataPlatformInstanceAspectMapper; +import com.linkedin.datahub.graphql.types.common.mappers.OwnershipMapper; +import com.linkedin.datahub.graphql.types.domain.DomainAssociationMapper; +import com.linkedin.datahub.graphql.types.glossary.mappers.GlossaryTermsMapper; +import com.linkedin.datahub.graphql.types.structuredproperty.StructuredPropertiesMapper; +import com.linkedin.datahub.graphql.types.tag.mappers.GlobalTagsMapper; +import com.linkedin.domain.Domains; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.metadata.Constants; +import com.linkedin.structured.StructuredProperties; +import javax.annotation.Nullable; + +/** Maps GMS EntityResponse representing a Document to a GraphQL Document object. */ +public class DocumentMapper { + + public static Document map(@Nullable QueryContext context, final EntityResponse entityResponse) { + final Document result = new Document(); + final Urn entityUrn = entityResponse.getUrn(); + final EnvelopedAspectMap aspects = entityResponse.getAspects(); + + result.setUrn(entityUrn.toString()); + result.setType(EntityType.DOCUMENT); + + // Map Document Info aspect + final EnvelopedAspect envelopedInfo = aspects.get(Constants.DOCUMENT_INFO_ASPECT_NAME); + if (envelopedInfo != null) { + result.setInfo( + mapDocumentInfo( + new com.linkedin.knowledge.DocumentInfo(envelopedInfo.getValue().data()), entityUrn)); + } + + // Map SubTypes aspect to subType field (get first type if available) + final EnvelopedAspect envelopedSubTypes = aspects.get(Constants.SUB_TYPES_ASPECT_NAME); + if (envelopedSubTypes != null) { + final SubTypes subTypes = new SubTypes(envelopedSubTypes.getValue().data()); + if (subTypes.hasTypeNames() && !subTypes.getTypeNames().isEmpty()) { + result.setSubType(subTypes.getTypeNames().get(0)); + } + } + + // Map DataPlatformInstance aspect + final EnvelopedAspect envelopedDataPlatformInstance = + aspects.get(Constants.DATA_PLATFORM_INSTANCE_ASPECT_NAME); + if (envelopedDataPlatformInstance != null) { + final DataPlatformInstance dataPlatformInstance = + new DataPlatformInstance(envelopedDataPlatformInstance.getValue().data()); + result.setDataPlatformInstance( + DataPlatformInstanceAspectMapper.map(context, dataPlatformInstance)); + } + + // Map Ownership aspect + final EnvelopedAspect envelopedOwnership = aspects.get(Constants.OWNERSHIP_ASPECT_NAME); + if (envelopedOwnership != null) { + result.setOwnership( + OwnershipMapper.map( + context, new Ownership(envelopedOwnership.getValue().data()), entityUrn)); + } + + // Map Browse Paths V2 aspect + final EnvelopedAspect envelopedBrowsePathsV2 = + aspects.get(Constants.BROWSE_PATHS_V2_ASPECT_NAME); + if (envelopedBrowsePathsV2 != null) { + result.setBrowsePathV2( + BrowsePathsV2Mapper.map( + context, new BrowsePathsV2(envelopedBrowsePathsV2.getValue().data()))); + } + + // Map Structured Properties aspect + final EnvelopedAspect envelopedStructuredProps = + aspects.get(Constants.STRUCTURED_PROPERTIES_ASPECT_NAME); + if (envelopedStructuredProps != null) { + result.setStructuredProperties( + StructuredPropertiesMapper.map( + context, + new StructuredProperties(envelopedStructuredProps.getValue().data()), + entityUrn)); + } + + // Map Global Tags aspect + final EnvelopedAspect envelopedGlobalTags = aspects.get(Constants.GLOBAL_TAGS_ASPECT_NAME); + if (envelopedGlobalTags != null) { + result.setTags( + GlobalTagsMapper.map( + context, new GlobalTags(envelopedGlobalTags.getValue().data()), entityUrn)); + } + + // Map Glossary Terms aspect + final EnvelopedAspect envelopedGlossaryTerms = + aspects.get(Constants.GLOSSARY_TERMS_ASPECT_NAME); + if (envelopedGlossaryTerms != null) { + result.setGlossaryTerms( + GlossaryTermsMapper.map( + context, new GlossaryTerms(envelopedGlossaryTerms.getValue().data()), entityUrn)); + } + + // Map Domains aspect + final EnvelopedAspect envelopedDomains = aspects.get(Constants.DOMAINS_ASPECT_NAME); + if (envelopedDomains != null) { + final Domains domains = new Domains(envelopedDomains.getValue().data()); + // domains.getDomains() returns a UrnArray + if (domains.hasDomains() && !domains.getDomains().isEmpty()) { + result.setDomain(DomainAssociationMapper.map(context, domains, entityUrn.toString())); + } + } + + // Map Status aspect for soft delete + final EnvelopedAspect envelopedStatus = aspects.get(Constants.STATUS_ASPECT_NAME); + if (envelopedStatus != null) { + result.setExists(!new Status(envelopedStatus.getValue().data()).isRemoved()); + } + + // Note: Relationships are handled separately via batch resolvers in GraphQL + // They will be resolved lazily when accessed through the GraphQL query + + if (context != null && !canView(context.getOperationContext(), entityUrn)) { + return com.linkedin.datahub.graphql.authorization.AuthorizationUtils.restrictEntity( + result, Document.class); + } else { + return result; + } + } + + /** Maps the Document Info PDL model to the GraphQL model */ + private static DocumentInfo mapDocumentInfo( + final com.linkedin.knowledge.DocumentInfo info, final Urn entityUrn) { + final DocumentInfo result = new DocumentInfo(); + + if (info.hasTitle()) { + result.setTitle(info.getTitle()); + } + + // Map source information if present + if (info.hasSource()) { + result.setSource(mapDocumentSource(info.getSource())); + } + + // Map status + if (info.hasStatus()) { + result.setStatus(mapDocumentStatus(info.getStatus())); + } + + // Map contents + final DocumentContent graphqlContent = new DocumentContent(); + graphqlContent.setText(info.getContents().getText()); + result.setContents(graphqlContent); + + // Map created audit stamp + result.setCreated(AuditStampMapper.map(null, info.getCreated())); + + // Map lastModified audit stamp + result.setLastModified(AuditStampMapper.map(null, info.getLastModified())); + + // Map related assets - create stubs that will be resolved by GraphQL batch loaders + if (info.hasRelatedAssets()) { + result.setRelatedAssets( + info.getRelatedAssets().stream() + .map( + asset -> { + final DocumentRelatedAsset assetInfo = new DocumentRelatedAsset(); + assetInfo.setAsset( + com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper.map( + null, asset.getAsset())); + return assetInfo; + }) + .collect(java.util.stream.Collectors.toList())); + } + + // Map related documents - create stubs that will be resolved by GraphQL batch loaders + if (info.hasRelatedDocuments()) { + result.setRelatedDocuments( + info.getRelatedDocuments().stream() + .map( + document -> { + final DocumentRelatedDocument documentInfo = new DocumentRelatedDocument(); + final Document stubDocument = new Document(); + stubDocument.setUrn(document.getDocument().toString()); + stubDocument.setType(EntityType.DOCUMENT); + documentInfo.setDocument(stubDocument); + return documentInfo; + }) + .collect(java.util.stream.Collectors.toList())); + } + + // Map parent document - create stub that will be resolved by GraphQL batch loaders + if (info.hasParentDocument()) { + final DocumentParentDocument parentInfo = new DocumentParentDocument(); + final Document stubParent = new Document(); + stubParent.setUrn(info.getParentDocument().getDocument().toString()); + stubParent.setType(EntityType.DOCUMENT); + parentInfo.setDocument(stubParent); + result.setParentDocument(parentInfo); + } + + // Map draftOf - create stub that will be resolved by GraphQL batch loaders + if (info.hasDraftOf()) { + final DocumentDraftOf draftOfInfo = new DocumentDraftOf(); + final Document stubDraftOf = new Document(); + stubDraftOf.setUrn(info.getDraftOf().getDocument().toString()); + stubDraftOf.setType(EntityType.DOCUMENT); + draftOfInfo.setDocument(stubDraftOf); + result.setDraftOf(draftOfInfo); + } + + // Map custom properties (included via CustomProperties mixin in PDL) + if (info.hasCustomProperties() && !info.getCustomProperties().isEmpty()) { + result.setCustomProperties(CustomPropertiesMapper.map(info.getCustomProperties(), entityUrn)); + } + + return result; + } + + /** Maps the Document Status PDL model to the GraphQL model */ + private static com.linkedin.datahub.graphql.generated.DocumentStatus mapDocumentStatus( + final com.linkedin.knowledge.DocumentStatus status) { + final com.linkedin.datahub.graphql.generated.DocumentStatus result = + new com.linkedin.datahub.graphql.generated.DocumentStatus(); + + // Map state + result.setState( + com.linkedin.datahub.graphql.generated.DocumentState.valueOf(status.getState().name())); + + return result; + } + + /** Maps the Document Source PDL model to the GraphQL model */ + private static com.linkedin.datahub.graphql.generated.DocumentSource mapDocumentSource( + final com.linkedin.knowledge.DocumentSource source) { + final com.linkedin.datahub.graphql.generated.DocumentSource result = + new com.linkedin.datahub.graphql.generated.DocumentSource(); + + // Map the PDL enum to the GraphQL enum + result.setSourceType( + com.linkedin.datahub.graphql.generated.DocumentSourceType.valueOf( + source.getSourceType().name())); + + if (source.hasExternalUrl()) { + result.setExternalUrl(source.getExternalUrl()); + } + + if (source.hasExternalId()) { + result.setExternalId(source.getExternalId()); + } + + return result; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/knowledge/DocumentType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/knowledge/DocumentType.java new file mode 100644 index 00000000000000..f7e45ea7a45a9a --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/knowledge/DocumentType.java @@ -0,0 +1,139 @@ +package com.linkedin.datahub.graphql.types.knowledge; + +import static com.linkedin.datahub.graphql.authorization.AuthorizationUtils.canView; + +import com.google.common.collect.ImmutableSet; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.AutoCompleteResults; +import com.linkedin.datahub.graphql.generated.Document; +import com.linkedin.datahub.graphql.generated.Entity; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.FacetFilterInput; +import com.linkedin.datahub.graphql.generated.SearchResults; +import com.linkedin.datahub.graphql.types.SearchableEntityType; +import com.linkedin.datahub.graphql.types.mappers.AutoCompleteResultsMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.query.AutoCompleteResult; +import com.linkedin.metadata.query.filter.Filter; +import graphql.execution.DataFetcherResult; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.apache.commons.lang3.NotImplementedException; + +/** GraphQL Type implementation for Document entity. Supports batch loading and autocomplete. */ +public class DocumentType + implements SearchableEntityType, + com.linkedin.datahub.graphql.types.EntityType { + + static final Set ASPECTS_TO_FETCH = + ImmutableSet.of( + Constants.DOCUMENT_KEY_ASPECT_NAME, + Constants.DOCUMENT_INFO_ASPECT_NAME, + Constants.OWNERSHIP_ASPECT_NAME, + Constants.STATUS_ASPECT_NAME, + Constants.BROWSE_PATHS_V2_ASPECT_NAME, + Constants.STRUCTURED_PROPERTIES_ASPECT_NAME, + Constants.DOMAINS_ASPECT_NAME, + Constants.SUB_TYPES_ASPECT_NAME, + Constants.DATA_PLATFORM_INSTANCE_ASPECT_NAME, + Constants.GLOBAL_TAGS_ASPECT_NAME, + Constants.GLOSSARY_TERMS_ASPECT_NAME); + + private final EntityClient _entityClient; + + public DocumentType(final EntityClient entityClient) { + _entityClient = entityClient; + } + + @Override + public EntityType type() { + return EntityType.DOCUMENT; + } + + @Override + public Function getKeyProvider() { + return Entity::getUrn; + } + + @Override + public Class objectClass() { + return Document.class; + } + + @Override + public List> batchLoad( + @Nonnull List urns, @Nonnull QueryContext context) throws Exception { + final List documentUrns = urns.stream().map(this::getUrn).collect(Collectors.toList()); + + try { + final Map entities = + _entityClient.batchGetV2( + context.getOperationContext(), + Constants.DOCUMENT_ENTITY_NAME, + documentUrns.stream() + .filter(urn -> canView(context.getOperationContext(), urn)) + .collect(Collectors.toSet()), + ASPECTS_TO_FETCH); + + final List gmsResults = new ArrayList<>(urns.size()); + for (Urn urn : documentUrns) { + gmsResults.add(entities.getOrDefault(urn, null)); + } + return gmsResults.stream() + .map( + gmsResult -> + gmsResult == null + ? null + : DataFetcherResult.newResult() + .data(DocumentMapper.map(context, gmsResult)) + .build()) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new RuntimeException("Failed to batch load Documents", e); + } + } + + @Override + public SearchResults search( + @Nonnull String query, + @Nullable List filters, + int start, + @Nullable Integer count, + @Nonnull final QueryContext context) + throws Exception { + throw new NotImplementedException( + "Searchable type (deprecated) not implemented on Document entity type. Use searchDocuments query instead."); + } + + @Override + public AutoCompleteResults autoComplete( + @Nonnull String query, + @Nullable String field, + @Nullable Filter filters, + @Nullable Integer limit, + @Nonnull final QueryContext context) + throws Exception { + final AutoCompleteResult result = + _entityClient.autoComplete( + context.getOperationContext(), Constants.DOCUMENT_ENTITY_NAME, query, filters, limit); + return AutoCompleteResultsMapper.map(context, result); + } + + private Urn getUrn(final String urnStr) { + try { + return Urn.createFromString(urnStr); + } catch (URISyntaxException e) { + throw new RuntimeException(String.format("Failed to convert urn string %s into Urn", urnStr)); + } + } +} diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index 6dc7a279701bec..a88e32761915b9 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -1347,6 +1347,11 @@ enum EntityType { """ APPLICATION + """ + A Knowledge Article + """ + DOCUMENT + """ An DataHub Page Template """ diff --git a/datahub-graphql-core/src/main/resources/knowledge.graphql b/datahub-graphql-core/src/main/resources/knowledge.graphql new file mode 100644 index 00000000000000..b04b468fbf689b --- /dev/null +++ b/datahub-graphql-core/src/main/resources/knowledge.graphql @@ -0,0 +1,662 @@ +extend type Mutation { + """ + Create a new Document. Returns the urn of the newly created document. + Requires the CREATE_ENTITY privilege for documents or MANAGE_DOCUMENTS platform privilege. + """ + createDocument(input: CreateDocumentInput!): String! + + """ + Update the contents of an existing Document. + Requires the EDIT_ENTITY_DOCS or EDIT_ENTITY privilege for the document, or MANAGE_DOCUMENTS platform privilege. + """ + updateDocumentContents(input: UpdateDocumentContentsInput!): Boolean! + + """ + Update the related entities (assets and documents) for a Document. + Requires the EDIT_ENTITY_DOCS or EDIT_ENTITY privilege for the document, or MANAGE_DOCUMENTS platform privilege. + """ + updateDocumentRelatedEntities( + input: UpdateDocumentRelatedEntitiesInput! + ): Boolean! + + """ + Move a Document to a different parent (or to root level if no parent is specified). + Requires the EDIT_ENTITY_DOCS or EDIT_ENTITY privilege for the document, or MANAGE_DOCUMENTS platform privilege. + """ + moveDocument(input: MoveDocumentInput!): Boolean! + + """ + Delete a Document. + Requires the GET_ENTITY privilege for the document or MANAGE_DOCUMENTS platform privilege. + """ + deleteDocument(urn: String!): Boolean! + + """ + Update the status of a Document (published/unpublished). + Requires the EDIT_ENTITY_DOCS or EDIT_ENTITY privilege for the document, or MANAGE_DOCUMENTS platform privilege. + """ + updateDocumentStatus(input: UpdateDocumentStatusInput!): Boolean! + + """ + Merge a draft document into its parent (the document it is a draft of). + This copies the draft's content to the published document and optionally deletes the draft. + Requires the EDIT_ENTITY_DOCS or EDIT_ENTITY privilege for both documents, or MANAGE_DOCUMENTS platform privilege. + """ + mergeDraft(input: MergeDraftInput!): Boolean! +} + +extend type Query { + """ + Get a Document by URN. + Requires the GET_ENTITY privilege for the document or MANAGE_DOCUMENTS platform privilege. + """ + document(urn: String!): Document + + """ + Search Documents with hybrid semantic search and filtering support. + Supports filtering by parent document, types, domains, and semantic query. + """ + searchDocuments(input: SearchDocumentsInput!): SearchDocumentsResult! +} + +""" +A Document entity in DataHub +""" +type Document implements Entity { + """ + The primary key of the Document + """ + urn: String! + + """ + A standard Entity Type + """ + type: EntityType! + + """ + Information about the Document + """ + info: DocumentInfo + + """ + The sub-type of the Document (e.g., "FAQ", "Tutorial", "Reference", etc.) + """ + subType: String + + """ + Data Platform Instance associated with the Document + """ + dataPlatformInstance: DataPlatformInstance + + """ + Ownership metadata of the Document + """ + ownership: Ownership + + """ + The browse path V2 corresponding to an entity. If no Browse Paths V2 have been generated before, this will be null. + """ + browsePathV2: BrowsePathV2 + + """ + Tags applied to the Document + """ + tags: GlobalTags + + """ + Glossary terms associated with the Document + """ + glossaryTerms: GlossaryTerms + + """ + The Domain associated with the Document + """ + domain: DomainAssociation + + """ + Whether or not this entity exists on DataHub + """ + exists: Boolean + + """ + Edges extending from this entity + """ + relationships(input: RelationshipsInput!): EntityRelationshipsResult + + """ + Experimental API. + For fetching extra entities that do not have custom UI code yet + """ + aspects(input: AspectParams): [RawAspect!] + + """ + Structured properties about this asset + """ + structuredProperties: StructuredProperties + + """ + Privileges given to a user relevant to this entity + """ + privileges: EntityPrivileges + + """ + All draft documents that have this document as their draftOf target. + These are UNPUBLISHED documents being worked on as potential new versions. + Note: This field requires a separate query/batch loader to fetch. + """ + drafts: [Document!] + + """ + Change history for this document. + Returns a chronological list of changes made to the document. + """ + changeHistory( + """ + Start time in milliseconds since epoch (optional). + Defaults to 30 days ago if not specified. + """ + startTimeMillis: Long + + """ + End time in milliseconds since epoch (optional). + Defaults to current time if not specified. + """ + endTimeMillis: Long + + """ + Maximum number of change entries to return. + Defaults to 50. + """ + limit: Int = 50 + ): [DocumentChange!]! +} + +""" +Information about a Document +""" +type DocumentInfo { + """ + Optional title for the document + """ + title: String + + """ + Information about the external source of this document. + Only populated for third-party documents ingested from external systems. + If null, the document is first-party (created directly in DataHub). + """ + source: DocumentSource + + """ + Status of the Document (published, unpublished, etc.) + """ + status: DocumentStatus + + """ + Content of the Document + """ + contents: DocumentContent! + + """ + The audit stamp for when the document was created + """ + created: AuditStamp! + + """ + The audit stamp for when the document was last modified (any field) + """ + lastModified: AuditStamp! + + """ + Assets referenced by or related to this Document + """ + relatedAssets: [DocumentRelatedAsset!] + + """ + Documents referenced by or related to this Document + """ + relatedDocuments: [DocumentRelatedDocument!] + + """ + The parent document of this Document + """ + parentDocument: DocumentParentDocument + + """ + If this document is a draft, the document it is a draft of. + When set, this document should be hidden from normal knowledge base browsing. + """ + draftOf: DocumentDraftOf + + """ + Custom properties of the Document + """ + customProperties: [CustomPropertiesEntry!] +} + +""" +The contents of a Document +""" +type DocumentContent { + """ + The text contents of the Document + """ + text: String! +} + +""" +The type of source for a document +""" +enum DocumentSourceType { + """ + Created via the DataHub UI or API + """ + NATIVE + + """ + The document was ingested from an external source + """ + EXTERNAL +} + +""" +Information about the external source of a document +""" +type DocumentSource { + """ + The type of the source + """ + sourceType: DocumentSourceType! + + """ + URL to the external source where this document originated + """ + externalUrl: String + + """ + Unique identifier in the external system + """ + externalId: String +} + +""" +A data asset referenced by a Document +""" +type DocumentRelatedAsset { + """ + The asset referenced by or related to the document + """ + asset: Entity! +} + +""" +A document referenced by or related to another Document +""" +type DocumentRelatedDocument { + """ + The document referenced by or related to the document + """ + document: Document! +} + +""" +The parent document of the document +""" +type DocumentParentDocument { + """ + The hierarchical parent document for this document + """ + document: Document! +} + +""" +Indicates this document is a draft of another document +""" +type DocumentDraftOf { + """ + The document that this document is a draft of + """ + document: Document! +} + +""" +Status information for a Document +""" +type DocumentStatus { + """ + The current state of the document + """ + state: DocumentState! +} + +""" +The state of a Document +""" +enum DocumentState { + """ + Document is published and visible to users + """ + PUBLISHED + + """ + Document is not published publically + """ + UNPUBLISHED +} + +""" +Input required to create a new Document +""" +input CreateDocumentInput { + """ + Optional! A custom id to use as the primary key identifier for the document. + If not provided, a random UUID will be generated as the id. + """ + id: String + + """ + The sub-type of the Document (e.g., "FAQ", "Tutorial", "Reference") + """ + subType: String! + + """ + Optional title for the document + """ + title: String + + """ + Optional initial state of the document. Defaults to UNPUBLISHED if not provided. + """ + state: DocumentState + + """ + Content of the Document + """ + contents: DocumentContentInput! + + """ + Optional owners for the document. If not provided, the creator is automatically added as an owner. + """ + owners: [OwnerInput!] + + """ + Optional URN of the parent document + """ + parentDocument: String + + """ + Optional URNs of related assets + """ + relatedAssets: [String!] + + """ + Optional URNs of related documents + """ + relatedDocuments: [String!] + + """ + If provided, the new document will be created as a draft of the specified published document URN. + Draft documents should have UNPUBLISHED state and will be hidden from normal knowledge base browsing. + """ + draftFor: String +} + +""" +Input for Document content +""" +input DocumentContentInput { + """ + The text contents of the Document + """ + text: String! +} + +""" +Input required to update the contents of a Document +""" +input UpdateDocumentContentsInput { + """ + The URN of the Document to update + """ + urn: String! + + """ + The new contents for the Document + """ + contents: DocumentContentInput! + + """ + Optional updated title for the document + """ + title: String + + """ + Optional updated sub-type for the document (e.g., "FAQ", "Tutorial", "Reference") + """ + subType: String +} + +""" +Input required to update the related entities of a Document +""" +input UpdateDocumentRelatedEntitiesInput { + """ + The URN of the Document to update + """ + urn: String! + + """ + Optional URNs of related assets (will replace existing) + """ + relatedAssets: [String!] + + """ + Optional URNs of related documents (will replace existing) + """ + relatedDocuments: [String!] +} + +""" +Input required to move a Document to a different parent +""" +input MoveDocumentInput { + """ + The URN of the Document to move + """ + urn: String! + + """ + Optional URN of the new parent document. If null, moves to root level. + """ + parentDocument: String +} + +""" +Input required to update the status of a Document +""" +input UpdateDocumentStatusInput { + """ + The URN of the Document to update + """ + urn: String! + + """ + The new state for the document + """ + state: DocumentState! +} + +""" +Input required when searching Documents +""" +input SearchDocumentsInput { + """ + The starting offset of the result set returned + """ + start: Int + + """ + The maximum number of Documents to be returned in the result set + """ + count: Int + + """ + Optional semantic search query to search across document contents and metadata + """ + query: String + + """ + Optional parent document URN to filter by (for hierarchical browsing) + """ + parentDocument: String + + """ + Optional list of document types to filter by (ANDed with other filters) + """ + types: [String!] + + """ + Optional list of domain URNs to filter by (ANDed with other filters) + """ + domains: [String!] + + """ + Optional list of document states to filter by (ANDed with other filters). + If not provided, defaults to PUBLISHED only. + """ + states: [DocumentState!] + + """ + Whether to include draft documents in the search results. + Draft documents have draftOf set and are hidden from normal browsing by default. + Defaults to false (excludes drafts). + """ + includeDrafts: Boolean + + """ + Optional facet filters to apply + """ + filters: [FacetFilterInput!] + + """ + Optional flags controlling search options + """ + searchFlags: SearchFlags +} + +""" +The result obtained when searching Documents +""" +type SearchDocumentsResult { + """ + The starting offset of the result set returned + """ + start: Int! + + """ + The number of Documents in the returned result set + """ + count: Int! + + """ + The total number of Documents in the result set + """ + total: Int! + + """ + The Documents themselves + """ + documents: [Document!]! + + """ + Facets for filtering search results + """ + facets: [FacetMetadata!] +} + +""" +Input required to merge a draft into its parent document +""" +input MergeDraftInput { + """ + The URN of the draft document to merge + """ + draftUrn: String! + + """ + Whether to delete the draft document after merging. Defaults to true. + """ + deleteDraft: Boolean +} + +""" +A change made to a document. +Represents a single modification with timestamp, actor, and description. +""" +type DocumentChange { + """ + Type of change that occurred + """ + changeType: DocumentChangeType! + + """ + Human-readable description of what changed + """ + description: String! + + """ + User who made the change (optional, may not be available for all changes) + """ + actor: CorpUser + + """ + When the change occurred (milliseconds since epoch) + """ + timestamp: Long! + + """ + Additional context about the change (optional). + For example, if a document was moved, this might contain the old and new parent URNs. + """ + details: [StringMapEntry!] +} + +""" +Types of changes that can occur to a document +""" +enum DocumentChangeType { + """ + Document was created + """ + CREATED + + """ + Document content or title was modified + """ + CONTENT_MODIFIED + + """ + Document was moved to a different parent + """ + PARENT_CHANGED + + """ + Relationships to other documents were added or removed + """ + RELATED_DOCUMENTS_CHANGED + + """ + Relationships to assets (datasets, dashboards, etc.) were added or removed + """ + RELATED_ASSETS_CHANGED + + """ + Document state changed (e.g., published <-> unpublished) + """ + STATE_CHANGED + + """ + Document was deleted + """ + DELETED +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/CreateDocumentResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/CreateDocumentResolverTest.java new file mode 100644 index 00000000000000..601063b22de7f5 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/CreateDocumentResolverTest.java @@ -0,0 +1,286 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.CreateDocumentInput; +import com.linkedin.datahub.graphql.generated.DocumentContentInput; +import com.linkedin.datahub.graphql.generated.OwnerEntityType; +import com.linkedin.datahub.graphql.generated.OwnerInput; +import com.linkedin.datahub.graphql.generated.OwnershipType; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.concurrent.CompletionException; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class CreateDocumentResolverTest { + + private static final Urn TEST_USER_URN = UrnUtils.getUrn("urn:li:corpuser:testUser"); + private static final Urn TEST_DOCUMENT_URN = UrnUtils.getUrn("urn:li:document:test-document"); + private static final Urn TEST_PUBLISHED_URN = + UrnUtils.getUrn("urn:li:document:published-document"); + private static final Urn TEST_DRAFT_URN = UrnUtils.getUrn("urn:li:document:draft-document"); + + private DocumentService mockService; + private EntityService mockEntityService; + private CreateDocumentResolver resolver; + private DataFetchingEnvironment mockEnv; + private CreateDocumentInput input; + + @BeforeMethod + public void setupTest() throws Exception { + mockService = mock(DocumentService.class); + mockEntityService = mock(EntityService.class); + mockEnv = mock(DataFetchingEnvironment.class); + + // Setup default input + input = new CreateDocumentInput(); + input.setSubType("tutorial"); + input.setTitle("Test Document"); + + DocumentContentInput contentInput = new DocumentContentInput(); + contentInput.setText("Test content"); + input.setContents(contentInput); + + // Mock the service to return a test URN + when(mockService.createDocument( + any(OperationContext.class), + any(), // id + any(), // subTypes list + any(), // title + any(), // source + any(), // state + any(), // content + any(), // parent + any(), // related assets + any(), // related documents + any(), // draftOfUrn + any(Urn.class))) // actor + .thenReturn(TEST_DOCUMENT_URN); + + resolver = new CreateDocumentResolver(mockService, mockEntityService); + } + + @Test + public void testCreateDocumentSuccess() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + when(mockContext.getActorUrn()).thenReturn(TEST_USER_URN.toString()); + + String result = resolver.get(mockEnv).get(); + + assertEquals(result, TEST_DOCUMENT_URN.toString()); + + // Verify service was called with NATIVE source type + verify(mockService, times(1)) + .createDocument( + any(OperationContext.class), + any(), // id + any(), // subTypes list (contains "tutorial") + eq("Test Document"), // title + argThat( + source -> + source != null + && source.getSourceType() + == com.linkedin.knowledge.DocumentSourceType.NATIVE), // source must be + // NATIVE + any(), // state parameter + any(), // contents + any(), // parent + any(), // related assets + any(), // related documents + any(), // draftOfUrn + any(Urn.class)); // actor URN + + // Verify ownership was set (default to creator) + verify(mockService, times(1)) + .setDocumentOwnership( + any(OperationContext.class), + eq(TEST_DOCUMENT_URN), + any(), // owners list + any(Urn.class)); // actor URN + } + + @Test + public void testCreateDocumentUnauthorized() throws Exception { + QueryContext mockContext = getMockDenyContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Verify service was NOT called + verify(mockService, times(0)) + .createDocument( + any(OperationContext.class), + any(), // id + any(), // subTypes + any(), // title + any(), // source + any(), // state + any(), // content + any(), // parent + any(), // related assets + any(), // related documents + any(), // draftOfUrn + any()); // actor + } + + @Test + public void testCreateDocumentWithCustomId() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + when(mockContext.getActorUrn()).thenReturn(TEST_USER_URN.toString()); + + input.setId("custom-id"); + + String result = resolver.get(mockEnv).get(); + + assertEquals(result, TEST_DOCUMENT_URN.toString()); + + // Verify custom ID was passed to service + verify(mockService, times(1)) + .createDocument( + any(OperationContext.class), + eq("custom-id"), // id + any(), // subTypes + any(), // title + any(), // source + any(), // state + any(), // content + any(), // parent + any(), // related assets + any(), // related documents + any(), // draftOfUrn + any()); // actor + } + + @Test + public void testCreateDocumentWithParent() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + when(mockContext.getActorUrn()).thenReturn(TEST_USER_URN.toString()); + + input.setParentDocument("urn:li:document:parent"); + + String result = resolver.get(mockEnv).get(); + + assertEquals(result, TEST_DOCUMENT_URN.toString()); + } + + @Test + public void testCreateDocumentWithCustomOwners() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + when(mockContext.getActorUrn()).thenReturn(TEST_USER_URN.toString()); + + // Add custom owners to input + OwnerInput owner1 = new OwnerInput(); + owner1.setOwnerUrn("urn:li:corpuser:owner1"); + owner1.setOwnerEntityType(OwnerEntityType.CORP_USER); + owner1.setType(OwnershipType.TECHNICAL_OWNER); + + OwnerInput owner2 = new OwnerInput(); + owner2.setOwnerUrn("urn:li:corpuser:owner2"); + owner2.setOwnerEntityType(OwnerEntityType.CORP_USER); + owner2.setType(OwnershipType.BUSINESS_OWNER); + + input.setOwners(java.util.Arrays.asList(owner1, owner2)); + + String result = resolver.get(mockEnv).get(); + + assertEquals(result, TEST_DOCUMENT_URN.toString()); + + // Verify ownership was set with the custom owners + verify(mockService, times(1)) + .setDocumentOwnership( + any(OperationContext.class), + eq(TEST_DOCUMENT_URN), + any(), // owners list (should contain 2 owners) + any(Urn.class)); + } + + @Test + public void testCreateDocumentServiceThrowsException() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + when(mockContext.getActorUrn()).thenReturn(TEST_USER_URN.toString()); + + when(mockService.createDocument( + any(OperationContext.class), + any(), // id + any(), // subTypes + any(), // title + any(), // source + any(), // state + any(), // content + any(), // parent + any(), // related assets + any(), // related documents + any(), // draftOfUrn + any())) // actor + .thenThrow(new RuntimeException("Service error")); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } + + @Test + public void testCreateDocumentDraft() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockContext.getActorUrn()).thenReturn(TEST_USER_URN.toString()); + + // Set draftFor to create a draft + input.setDraftFor(TEST_PUBLISHED_URN.toString()); + + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + when(mockService.createDocument( + any(OperationContext.class), + any(), // id + any(), // subTypes list + any(), // title + any(), // source + any(), // state + any(), // content + any(), // parent + any(), // related assets + any(), // related documents + any(), // draftOfUrn + any(Urn.class))) // actor + .thenReturn(TEST_DRAFT_URN); + + String result = resolver.get(mockEnv).get(); + + assertEquals(result, TEST_DRAFT_URN.toString()); + + // Verify document was created with UNPUBLISHED state and draftOf set + verify(mockService, times(1)) + .createDocument( + any(OperationContext.class), + any(), // id + any(), // subTypes + eq("Test Document"), // title + any(), // source + eq(com.linkedin.knowledge.DocumentState.UNPUBLISHED), // state forced to UNPUBLISHED + any(), // content + any(), // parent + any(), // related assets + any(), // related documents + eq(TEST_PUBLISHED_URN), // draftOfUrn + any(Urn.class)); // actor + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/DeleteDocumentResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/DeleteDocumentResolverTest.java new file mode 100644 index 00000000000000..1c312950058cf7 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/DeleteDocumentResolverTest.java @@ -0,0 +1,73 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.concurrent.CompletionException; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class DeleteDocumentResolverTest { + + private static final String TEST_ARTICLE_URN = "urn:li:document:test-document"; + + private DocumentService mockService; + private DeleteDocumentResolver resolver; + private DataFetchingEnvironment mockEnv; + + @BeforeMethod + public void setupTest() { + mockService = mock(DocumentService.class); + mockEnv = mock(DataFetchingEnvironment.class); + resolver = new DeleteDocumentResolver(mockService); + } + + @Test + public void testDeleteArticleSuccess() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("urn"))).thenReturn(TEST_ARTICLE_URN); + + Boolean result = resolver.get(mockEnv).get(); + + assertTrue(result); + + // Verify service was called + verify(mockService, times(1)) + .deleteDocument(any(OperationContext.class), eq(UrnUtils.getUrn(TEST_ARTICLE_URN))); + } + + @Test + public void testDeleteArticleUnauthorized() throws Exception { + QueryContext mockContext = getMockDenyContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("urn"))).thenReturn(TEST_ARTICLE_URN); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Verify service was NOT called + verify(mockService, times(0)).deleteDocument(any(OperationContext.class), any(Urn.class)); + } + + @Test + public void testDeleteArticleServiceThrowsException() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("urn"))).thenReturn(TEST_ARTICLE_URN); + + doThrow(new RuntimeException("Service error")) + .when(mockService) + .deleteDocument(any(OperationContext.class), any(Urn.class)); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentChangeHistoryResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentChangeHistoryResolverTest.java new file mode 100644 index 00000000000000..06e5d6b08aaba1 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentChangeHistoryResolverTest.java @@ -0,0 +1,346 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +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.Document; +import com.linkedin.datahub.graphql.generated.DocumentChange; +import com.linkedin.datahub.graphql.generated.DocumentChangeType; +import com.linkedin.metadata.timeline.TimelineService; +import com.linkedin.metadata.timeline.data.ChangeCategory; +import com.linkedin.metadata.timeline.data.ChangeEvent; +import com.linkedin.metadata.timeline.data.ChangeOperation; +import com.linkedin.metadata.timeline.data.ChangeTransaction; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class DocumentChangeHistoryResolverTest { + + private static final Urn TEST_DOCUMENT_URN = UrnUtils.getUrn("urn:li:document:test-doc"); + private static final Urn TEST_USER_URN = UrnUtils.getUrn("urn:li:corpuser:testUser"); + + private TimelineService mockTimelineService; + private DocumentChangeHistoryResolver resolver; + private DataFetchingEnvironment mockEnv; + private QueryContext mockContext; + private Document sourceDocument; + + @BeforeMethod + public void setupTest() { + mockTimelineService = mock(TimelineService.class); + mockEnv = mock(DataFetchingEnvironment.class); + mockContext = mock(QueryContext.class); + + resolver = new DocumentChangeHistoryResolver(mockTimelineService); + + // Setup source document + sourceDocument = new Document(); + sourceDocument.setUrn(TEST_DOCUMENT_URN.toString()); + + when(mockEnv.getSource()).thenReturn(sourceDocument); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockContext.getOperationContext()).thenReturn(mock(OperationContext.class)); + } + + @Test + public void testGetChangeHistorySuccess() throws Exception { + // Setup timeline service to return change events + List transactions = new ArrayList<>(); + + // Create a document creation event + AuditStamp auditStamp = new AuditStamp(); + auditStamp.setTime(System.currentTimeMillis()); + auditStamp.setActor(TEST_USER_URN); + + ChangeEvent createEvent = + ChangeEvent.builder() + .category(ChangeCategory.LIFECYCLE) + .operation(ChangeOperation.CREATE) + .entityUrn(TEST_DOCUMENT_URN.toString()) + .description("Document 'Test Doc' was created") + .auditStamp(auditStamp) + .build(); + + ChangeTransaction transaction = + ChangeTransaction.builder() + .changeEvents(List.of(createEvent)) + .timestamp(auditStamp.getTime()) + .build(); + transactions.add(transaction); + + when(mockTimelineService.getTimeline( + eq(TEST_DOCUMENT_URN), + any(Set.class), + anyLong(), + anyLong(), + isNull(), + isNull(), + eq(false))) + .thenReturn(transactions); + + // Execute + List result = resolver.get(mockEnv).get(); + + // Verify + assertNotNull(result); + assertEquals(result.size(), 1); + DocumentChange change = result.get(0); + assertEquals(change.getChangeType(), DocumentChangeType.CREATED); + assertEquals(change.getDescription(), "Document 'Test Doc' was created"); + assertNotNull(change.getActor()); + assertEquals(change.getActor().getUrn(), TEST_USER_URN.toString()); + assertEquals(change.getTimestamp(), auditStamp.getTime()); + } + + @Test + public void testGetChangeHistoryWithContentModification() throws Exception { + List transactions = new ArrayList<>(); + + AuditStamp auditStamp = new AuditStamp(); + auditStamp.setTime(System.currentTimeMillis()); + auditStamp.setActor(TEST_USER_URN); + + // Content modification event + ChangeEvent contentEvent = + ChangeEvent.builder() + .category(ChangeCategory.DOCUMENTATION) + .operation(ChangeOperation.MODIFY) + .entityUrn(TEST_DOCUMENT_URN.toString()) + .description("Document title changed from 'Old Title' to 'New Title'") + .auditStamp(auditStamp) + .build(); + + ChangeTransaction transaction = + ChangeTransaction.builder().changeEvents(List.of(contentEvent)).build(); + transactions.add(transaction); + + when(mockTimelineService.getTimeline( + any(Urn.class), any(Set.class), anyLong(), anyLong(), isNull(), isNull(), eq(false))) + .thenReturn(transactions); + + List result = resolver.get(mockEnv).get(); + + assertNotNull(result); + assertEquals(result.size(), 1); + assertEquals(result.get(0).getChangeType(), DocumentChangeType.CONTENT_MODIFIED); + } + + @Test + public void testGetChangeHistoryWithParentChange() throws Exception { + List transactions = new ArrayList<>(); + + AuditStamp auditStamp = new AuditStamp(); + auditStamp.setTime(System.currentTimeMillis()); + auditStamp.setActor(TEST_USER_URN); + + Map params = new HashMap<>(); + params.put("oldParent", "urn:li:document:old-parent"); + params.put("newParent", "urn:li:document:new-parent"); + + ChangeEvent parentEvent = + ChangeEvent.builder() + .category(ChangeCategory.TAG) // Using TAG as proxy + .operation(ChangeOperation.MODIFY) + .entityUrn(TEST_DOCUMENT_URN.toString()) + .description("Document moved from old parent to new parent") + .auditStamp(auditStamp) + .parameters(params) + .build(); + + ChangeTransaction transaction = + ChangeTransaction.builder().changeEvents(List.of(parentEvent)).build(); + transactions.add(transaction); + + when(mockTimelineService.getTimeline( + any(Urn.class), any(Set.class), anyLong(), anyLong(), isNull(), isNull(), eq(false))) + .thenReturn(transactions); + + List result = resolver.get(mockEnv).get(); + + assertNotNull(result); + assertEquals(result.size(), 1); + DocumentChange change = result.get(0); + assertEquals(change.getChangeType(), DocumentChangeType.PARENT_CHANGED); + assertNotNull(change.getDetails()); + assertEquals(change.getDetails().size(), 2); + } + + @Test + public void testGetChangeHistoryWithStateChange() throws Exception { + List transactions = new ArrayList<>(); + + AuditStamp auditStamp = new AuditStamp(); + auditStamp.setTime(System.currentTimeMillis()); + auditStamp.setActor(TEST_USER_URN); + + ChangeEvent stateEvent = + ChangeEvent.builder() + .category(ChangeCategory.LIFECYCLE) + .operation(ChangeOperation.MODIFY) + .entityUrn(TEST_DOCUMENT_URN.toString()) + .description("Document state changed from UNPUBLISHED to PUBLISHED") + .auditStamp(auditStamp) + .build(); + + ChangeTransaction transaction = + ChangeTransaction.builder().changeEvents(List.of(stateEvent)).build(); + transactions.add(transaction); + + when(mockTimelineService.getTimeline( + any(Urn.class), any(Set.class), anyLong(), anyLong(), isNull(), isNull(), eq(false))) + .thenReturn(transactions); + + List result = resolver.get(mockEnv).get(); + + assertNotNull(result); + assertEquals(result.size(), 1); + assertEquals(result.get(0).getChangeType(), DocumentChangeType.STATE_CHANGED); + } + + @Test + public void testGetChangeHistoryWithCustomTimeRange() throws Exception { + long startTime = System.currentTimeMillis() - 86400000; // 1 day ago + long endTime = System.currentTimeMillis(); + + when(mockEnv.getArgument("startTimeMillis")).thenReturn(startTime); + when(mockEnv.getArgument("endTimeMillis")).thenReturn(endTime); + when(mockEnv.getArgument("limit")).thenReturn(100); + + when(mockTimelineService.getTimeline( + eq(TEST_DOCUMENT_URN), + any(Set.class), + eq(startTime), + eq(endTime), + isNull(), + isNull(), + eq(false))) + .thenReturn(new ArrayList<>()); + + List result = resolver.get(mockEnv).get(); + + assertNotNull(result); + verify(mockTimelineService, times(1)) + .getTimeline( + eq(TEST_DOCUMENT_URN), + any(Set.class), + eq(startTime), + eq(endTime), + isNull(), + isNull(), + eq(false)); + } + + @Test + public void testGetChangeHistoryMultipleChanges() throws Exception { + List transactions = new ArrayList<>(); + + AuditStamp auditStamp1 = new AuditStamp(); + auditStamp1.setTime(1000L); + auditStamp1.setActor(TEST_USER_URN); + + AuditStamp auditStamp2 = new AuditStamp(); + auditStamp2.setTime(2000L); + auditStamp2.setActor(TEST_USER_URN); + + ChangeEvent event1 = + ChangeEvent.builder() + .category(ChangeCategory.LIFECYCLE) + .operation(ChangeOperation.CREATE) + .description("Document created") + .auditStamp(auditStamp1) + .build(); + + ChangeEvent event2 = + ChangeEvent.builder() + .category(ChangeCategory.DOCUMENTATION) + .operation(ChangeOperation.MODIFY) + .description("Content modified") + .auditStamp(auditStamp2) + .build(); + + ChangeTransaction transaction1 = + ChangeTransaction.builder().changeEvents(List.of(event1)).build(); + ChangeTransaction transaction2 = + ChangeTransaction.builder().changeEvents(List.of(event2)).build(); + transactions.add(transaction1); + transactions.add(transaction2); + + when(mockTimelineService.getTimeline( + any(Urn.class), any(Set.class), anyLong(), anyLong(), isNull(), isNull(), eq(false))) + .thenReturn(transactions); + + List result = resolver.get(mockEnv).get(); + + assertNotNull(result); + assertEquals(result.size(), 2); + // Should be sorted by timestamp descending (most recent first) + assertTrue(result.get(0).getTimestamp() >= result.get(1).getTimestamp()); + } + + @Test(expectedExceptions = Exception.class) + public void testGetChangeHistoryServiceThrowsException() throws Exception { + when(mockTimelineService.getTimeline( + any(Urn.class), any(Set.class), anyLong(), anyLong(), isNull(), isNull(), eq(false))) + .thenThrow(new RuntimeException("Service error")); + + // Should throw an exception when service fails + resolver.get(mockEnv).get(); + } + + @Test + public void testGetChangeHistoryEmptyResult() throws Exception { + when(mockTimelineService.getTimeline( + any(Urn.class), any(Set.class), anyLong(), anyLong(), isNull(), isNull(), eq(false))) + .thenReturn(new ArrayList<>()); + + List result = resolver.get(mockEnv).get(); + + assertNotNull(result); + assertEquals(result.size(), 0); + } + + @Test + public void testGetChangeHistoryRespectsLimit() throws Exception { + // Create more changes than the limit + List transactions = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + AuditStamp auditStamp = new AuditStamp(); + auditStamp.setTime(System.currentTimeMillis() + i); + auditStamp.setActor(TEST_USER_URN); + + ChangeEvent event = + ChangeEvent.builder() + .category(ChangeCategory.DOCUMENTATION) + .operation(ChangeOperation.MODIFY) + .description("Change " + i) + .auditStamp(auditStamp) + .build(); + + ChangeTransaction transaction = + ChangeTransaction.builder().changeEvents(List.of(event)).build(); + transactions.add(transaction); + } + + when(mockEnv.getArgument("limit")).thenReturn(10); + when(mockTimelineService.getTimeline( + any(Urn.class), any(Set.class), anyLong(), anyLong(), isNull(), isNull(), eq(false))) + .thenReturn(transactions); + + List result = resolver.get(mockEnv).get(); + + assertNotNull(result); + assertEquals(result.size(), 10); // Should respect the limit + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentDraftsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentDraftsResolverTest.java new file mode 100644 index 00000000000000..8e0e29641d0e23 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentDraftsResolverTest.java @@ -0,0 +1,116 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.Document; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.metadata.search.SearchEntity; +import com.linkedin.metadata.search.SearchEntityArray; +import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.List; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class DocumentDraftsResolverTest { + + private static final Urn TEST_PUBLISHED_URN = + UrnUtils.getUrn("urn:li:document:published-document"); + private static final Urn TEST_DRAFT_1_URN = UrnUtils.getUrn("urn:li:document:draft-1"); + private static final Urn TEST_DRAFT_2_URN = UrnUtils.getUrn("urn:li:document:draft-2"); + + private DocumentService mockService; + private DocumentDraftsResolver resolver; + private DataFetchingEnvironment mockEnv; + private QueryContext mockContext; + private Document sourceDocument; + + @BeforeMethod + public void setupTest() throws Exception { + mockService = mock(DocumentService.class); + mockEnv = mock(DataFetchingEnvironment.class); + mockContext = mock(QueryContext.class); + when(mockContext.getOperationContext()).thenReturn(mock(OperationContext.class)); + + // Setup source document + sourceDocument = new Document(); + sourceDocument.setUrn(TEST_PUBLISHED_URN.toString()); + sourceDocument.setType(EntityType.DOCUMENT); + + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getSource()).thenReturn(sourceDocument); + + resolver = new DocumentDraftsResolver(mockService); + } + + @Test + public void testGetDraftsSuccess() throws Exception { + // Mock search results + SearchEntity draft1 = new SearchEntity(); + draft1.setEntity(TEST_DRAFT_1_URN); + + SearchEntity draft2 = new SearchEntity(); + draft2.setEntity(TEST_DRAFT_2_URN); + + SearchResult searchResult = new SearchResult(); + SearchEntityArray entities = new SearchEntityArray(); + entities.add(draft1); + entities.add(draft2); + searchResult.setEntities(entities); + + when(mockService.getDraftDocuments( + any(OperationContext.class), any(Urn.class), anyInt(), anyInt())) + .thenReturn(searchResult); + + List result = resolver.get(mockEnv).get(); + + assertNotNull(result); + assertEquals(result.size(), 2); + assertEquals(result.get(0).getUrn(), TEST_DRAFT_1_URN.toString()); + assertEquals(result.get(0).getType(), EntityType.DOCUMENT); + assertEquals(result.get(1).getUrn(), TEST_DRAFT_2_URN.toString()); + assertEquals(result.get(1).getType(), EntityType.DOCUMENT); + + // Verify service was called + verify(mockService, times(1)) + .getDraftDocuments(any(OperationContext.class), any(Urn.class), anyInt(), anyInt()); + } + + @Test + public void testGetDraftsNoDrafts() throws Exception { + // Mock empty search results + SearchResult searchResult = new SearchResult(); + searchResult.setEntities(new SearchEntityArray()); + + when(mockService.getDraftDocuments( + any(OperationContext.class), any(Urn.class), anyInt(), anyInt())) + .thenReturn(searchResult); + + List result = resolver.get(mockEnv).get(); + + assertNotNull(result); + assertEquals(result.size(), 0); + } + + @Test + public void testGetDraftsServiceThrowsException() throws Exception { + when(mockService.getDraftDocuments( + any(OperationContext.class), any(Urn.class), anyInt(), anyInt())) + .thenThrow(new RuntimeException("Service error")); + + try { + resolver.get(mockEnv).get(); + fail("Expected RuntimeException to be thrown"); + } catch (Exception e) { + assertTrue(e.getMessage().contains("Failed to fetch draft documents")); + } + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentResolversTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentResolversTest.java new file mode 100644 index 00000000000000..caf4a09b8d3886 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/DocumentResolversTest.java @@ -0,0 +1,78 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertNotNull; + +import com.linkedin.datahub.graphql.types.knowledge.DocumentType; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.graph.GraphClient; +import com.linkedin.metadata.models.registry.EntityRegistry; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.idl.RuntimeWiring; +import java.util.List; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class DocumentResolversTest { + + private DocumentService mockService; + private DocumentType mockType; + private EntityClient mockEntityClient; + private EntityService mockEntityService; + private GraphClient mockGraphClient; + private EntityRegistry mockEntityRegistry; + private com.linkedin.metadata.timeline.TimelineService mockTimelineService; + private DocumentResolvers resolvers; + + @BeforeMethod + public void setUp() { + mockService = mock(DocumentService.class); + mockType = mock(DocumentType.class); + mockEntityClient = mock(EntityClient.class); + mockEntityService = mock(EntityService.class); + mockGraphClient = mock(GraphClient.class); + mockEntityRegistry = mock(EntityRegistry.class); + mockTimelineService = mock(com.linkedin.metadata.timeline.TimelineService.class); + + resolvers = + new DocumentResolvers( + mockService, + (List) java.util.Collections.emptyList(), + mockType, + mockEntityClient, + mockEntityService, + mockGraphClient, + mockEntityRegistry, + mockTimelineService); + } + + @Test + public void testConstructor() { + assertNotNull(resolvers); + } + + @Test + public void testConfigureResolvers() { + RuntimeWiring.Builder mockBuilder = mock(RuntimeWiring.Builder.class); + when(mockBuilder.type(anyString(), any())).thenReturn(mockBuilder); + + resolvers.configureResolvers(mockBuilder); + + // Verify Query and Mutation types were configured + verify(mockBuilder, times(1)).type(eq("Query"), any()); + verify(mockBuilder, times(1)).type(eq("Mutation"), any()); + + // Verify Document type and related info types are wired + verify(mockBuilder, times(1)).type(eq("Document"), any()); + verify(mockBuilder, times(1)).type(eq("DocumentRelatedAsset"), any()); + verify(mockBuilder, times(1)).type(eq("DocumentRelatedDocument"), any()); + verify(mockBuilder, times(1)).type(eq("DocumentParentDocument"), any()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/MergeDraftResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/MergeDraftResolverTest.java new file mode 100644 index 00000000000000..8666425acaa3e5 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/MergeDraftResolverTest.java @@ -0,0 +1,112 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.MergeDraftInput; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.concurrent.CompletionException; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class MergeDraftResolverTest { + + private static final Urn TEST_USER_URN = UrnUtils.getUrn("urn:li:corpuser:testUser"); + private static final Urn TEST_DRAFT_URN = UrnUtils.getUrn("urn:li:document:draft-document"); + + private DocumentService mockService; + private EntityService mockEntityService; + private MergeDraftResolver resolver; + private DataFetchingEnvironment mockEnv; + private MergeDraftInput input; + + @BeforeMethod + public void setupTest() throws Exception { + mockService = mock(DocumentService.class); + mockEntityService = mock(EntityService.class); + mockEnv = mock(DataFetchingEnvironment.class); + + // Setup default input + input = new MergeDraftInput(); + input.setDraftUrn(TEST_DRAFT_URN.toString()); + input.setDeleteDraft(true); + + resolver = new MergeDraftResolver(mockService, mockEntityService); + } + + @Test + public void testMergeDraftSuccess() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + when(mockContext.getActorUrn()).thenReturn(TEST_USER_URN.toString()); + + Boolean result = resolver.get(mockEnv).get(); + + assertTrue(result); + + // Verify service was called with correct parameters + verify(mockService, times(1)) + .mergeDraftIntoParent( + any(OperationContext.class), + eq(TEST_DRAFT_URN), + eq(true), // deleteDraft + any(Urn.class)); // actor + } + + @Test + public void testMergeDraftWithoutDelete() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + when(mockContext.getActorUrn()).thenReturn(TEST_USER_URN.toString()); + + input.setDeleteDraft(false); + + Boolean result = resolver.get(mockEnv).get(); + + assertTrue(result); + + // Verify deleteDraft was false + verify(mockService, times(1)) + .mergeDraftIntoParent( + any(OperationContext.class), eq(TEST_DRAFT_URN), eq(false), any(Urn.class)); + } + + @Test + public void testMergeDraftUnauthorized() throws Exception { + QueryContext mockContext = getMockDenyContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Verify service was NOT called + verify(mockService, times(0)) + .mergeDraftIntoParent(any(OperationContext.class), any(), any(Boolean.class), any()); + } + + @Test + public void testMergeDraftServiceThrowsException() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + when(mockContext.getActorUrn()).thenReturn(TEST_USER_URN.toString()); + + doThrow(new RuntimeException("Service error")) + .when(mockService) + .mergeDraftIntoParent( + any(OperationContext.class), any(), any(Boolean.class), any(Urn.class)); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/MoveDocumentResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/MoveDocumentResolverTest.java new file mode 100644 index 00000000000000..1988671d1d2c6c --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/MoveDocumentResolverTest.java @@ -0,0 +1,107 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.MoveDocumentInput; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.concurrent.CompletionException; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class MoveDocumentResolverTest { + + private static final String TEST_ARTICLE_URN = "urn:li:document:test-document"; + private static final String TEST_PARENT_URN = "urn:li:document:parent-document"; + + private DocumentService mockService; + private MoveDocumentResolver resolver; + private DataFetchingEnvironment mockEnv; + private MoveDocumentInput input; + + @BeforeMethod + public void setupTest() { + mockService = mock(DocumentService.class); + mockEnv = mock(DataFetchingEnvironment.class); + + // Setup default input + input = new MoveDocumentInput(); + input.setUrn(TEST_ARTICLE_URN); + input.setParentDocument(TEST_PARENT_URN); + + resolver = new MoveDocumentResolver(mockService); + } + + @Test + public void testMoveArticleSuccess() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + Boolean result = resolver.get(mockEnv).get(); + + assertTrue(result); + + // Verify service was called + verify(mockService, times(1)) + .moveDocument( + any(OperationContext.class), + eq(UrnUtils.getUrn(TEST_ARTICLE_URN)), + eq(UrnUtils.getUrn(TEST_PARENT_URN)), + any(Urn.class)); + } + + @Test + public void testMoveArticleToRoot() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + input.setParentDocument(null); // Move to root + + Boolean result = resolver.get(mockEnv).get(); + + assertTrue(result); + + // Verify service was called with null parent + verify(mockService, times(1)) + .moveDocument( + any(OperationContext.class), + eq(UrnUtils.getUrn(TEST_ARTICLE_URN)), + eq(null), + any(Urn.class)); + } + + @Test + public void testMoveArticleUnauthorized() throws Exception { + QueryContext mockContext = getMockDenyContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Verify service was NOT called + verify(mockService, times(0)).moveDocument(any(OperationContext.class), any(), any(), any()); + } + + @Test + public void testMoveArticleServiceThrowsException() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + doThrow(new RuntimeException("Service error")) + .when(mockService) + .moveDocument(any(OperationContext.class), any(), any(), any()); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/SearchDocumentsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/SearchDocumentsResolverTest.java new file mode 100644 index 00000000000000..cec789ab07fd6a --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/SearchDocumentsResolverTest.java @@ -0,0 +1,239 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import com.google.common.collect.ImmutableList; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.DocumentState; +import com.linkedin.datahub.graphql.generated.SearchDocumentsInput; +import com.linkedin.datahub.graphql.generated.SearchDocumentsResult; +import com.linkedin.metadata.search.SearchEntity; +import com.linkedin.metadata.search.SearchEntityArray; +import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.search.SearchResultMetadata; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.concurrent.CompletionException; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class SearchDocumentsResolverTest { + + private static final String TEST_DOCUMENT_URN = "urn:li:document:test-document"; + + private DocumentService mockService; + private SearchDocumentsResolver resolver; + private DataFetchingEnvironment mockEnv; + private SearchDocumentsInput input; + + @BeforeMethod + public void setupTest() throws Exception { + mockService = mock(DocumentService.class); + mockEnv = mock(DataFetchingEnvironment.class); + + // Setup default input + input = new SearchDocumentsInput(); + input.setQuery("test query"); + input.setStart(0); + input.setCount(10); + + // Setup mock search result + SearchResult searchResult = new SearchResult(); + searchResult.setFrom(0); + searchResult.setPageSize(10); + searchResult.setNumEntities(1); + searchResult.setEntities( + new SearchEntityArray( + ImmutableList.of(new SearchEntity().setEntity(UrnUtils.getUrn(TEST_DOCUMENT_URN))))); + searchResult.setMetadata(new SearchResultMetadata()); + + when(mockService.searchDocuments( + any(OperationContext.class), + any(String.class), + any(), + any(), + any(Integer.class), + any(Integer.class))) + .thenReturn(searchResult); + + resolver = new SearchDocumentsResolver(mockService); + } + + @Test + public void testSearchDocumentsSuccess() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + SearchDocumentsResult result = resolver.get(mockEnv).get(); + + assertNotNull(result); + assertEquals(result.getStart(), 0); + assertEquals(result.getCount(), 10); + assertEquals(result.getTotal(), 1); + assertEquals(result.getDocuments().size(), 1); + + // Verify service was called + verify(mockService, times(1)) + .searchDocuments( + any(OperationContext.class), eq("test query"), any(), any(), eq(0), eq(10)); + } + + @Test + public void testSearchDocumentsWithFilters() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + input.setTypes(ImmutableList.of("tutorial", "guide")); + input.setParentDocument("urn:li:document:parent"); + + SearchDocumentsResult result = resolver.get(mockEnv).get(); + + assertNotNull(result); + + // Verify service was called with filters + verify(mockService, times(1)) + .searchDocuments( + any(OperationContext.class), eq("test query"), any(), any(), eq(0), eq(10)); + } + + @Test + public void testSearchDocumentsEmptyQuery() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + input.setQuery(null); // Empty query should default to "*" + + SearchDocumentsResult result = resolver.get(mockEnv).get(); + + assertNotNull(result); + + // Verify service was called with "*" query + verify(mockService, times(1)) + .searchDocuments(any(OperationContext.class), eq("*"), any(), any(), eq(0), eq(10)); + } + + // Note: Search operations don't require special authorization like other entity searches + // Authorization is applied at the entity level when viewing individual documents + + @Test + public void testSearchDocumentsServiceThrowsException() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + when(mockService.searchDocuments( + any(OperationContext.class), + any(), + any(), + any(), + any(Integer.class), + any(Integer.class))) + .thenThrow(new RuntimeException("Service error")); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } + + @Test + public void testSearchDocumentsDefaultsToPublishedState() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + // Don't set any states - should default to PUBLISHED + input.setStates(null); + + SearchDocumentsResult result = resolver.get(mockEnv).get(); + + assertNotNull(result); + + // Verify service was called (the filter will contain state=PUBLISHED by default) + verify(mockService, times(1)) + .searchDocuments( + any(OperationContext.class), eq("test query"), any(), any(), eq(0), eq(10)); + } + + @Test + public void testSearchDocumentsWithSingleState() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + // Set to only search UNPUBLISHED documents + input.setStates(ImmutableList.of(DocumentState.UNPUBLISHED)); + + SearchDocumentsResult result = resolver.get(mockEnv).get(); + + assertNotNull(result); + + // Verify service was called with UNPUBLISHED state filter + verify(mockService, times(1)) + .searchDocuments( + any(OperationContext.class), eq("test query"), any(), any(), eq(0), eq(10)); + } + + @Test + public void testSearchDocumentsWithMultipleStates() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + // Set to search both PUBLISHED and UNPUBLISHED documents + input.setStates(ImmutableList.of(DocumentState.PUBLISHED, DocumentState.UNPUBLISHED)); + + SearchDocumentsResult result = resolver.get(mockEnv).get(); + + assertNotNull(result); + + // Verify service was called with both states in filter + verify(mockService, times(1)) + .searchDocuments( + any(OperationContext.class), eq("test query"), any(), any(), eq(0), eq(10)); + } + + @Test + public void testSearchDocumentsExcludesDraftsByDefault() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + // Don't set includeDrafts - should exclude drafts by default + input.setIncludeDrafts(null); + + SearchDocumentsResult result = resolver.get(mockEnv).get(); + + assertNotNull(result); + + // Verify service was called (the filter will exclude draftOf != null by default) + verify(mockService, times(1)) + .searchDocuments( + any(OperationContext.class), eq("test query"), any(), any(), eq(0), eq(10)); + } + + @Test + public void testSearchDocumentsIncludeDrafts() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + // Explicitly include drafts + input.setIncludeDrafts(true); + + SearchDocumentsResult result = resolver.get(mockEnv).get(); + + assertNotNull(result); + + // Verify service was called without draftOf filter + verify(mockService, times(1)) + .searchDocuments( + any(OperationContext.class), eq("test query"), any(), any(), eq(0), eq(10)); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentContentsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentContentsResolverTest.java new file mode 100644 index 00000000000000..3e43a4c2d6eb26 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentContentsResolverTest.java @@ -0,0 +1,138 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.DocumentContentInput; +import com.linkedin.datahub.graphql.generated.UpdateDocumentContentsInput; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.concurrent.CompletionException; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class UpdateDocumentContentsResolverTest { + + private static final String TEST_ARTICLE_URN = "urn:li:document:test-document"; + + private DocumentService mockService; + private UpdateDocumentContentsResolver resolver; + private DataFetchingEnvironment mockEnv; + private UpdateDocumentContentsInput input; + + @BeforeMethod + public void setupTest() { + mockService = mock(DocumentService.class); + mockEnv = mock(DataFetchingEnvironment.class); + + // Setup default input + input = new UpdateDocumentContentsInput(); + input.setUrn(TEST_ARTICLE_URN); + + DocumentContentInput contentInput = new DocumentContentInput(); + contentInput.setText("Updated content"); + input.setContents(contentInput); + + resolver = new UpdateDocumentContentsResolver(mockService); + } + + @Test + public void testUpdateContentsSuccess() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + Boolean result = resolver.get(mockEnv).get(); + + assertTrue(result); + + // Verify service was called + verify(mockService, times(1)) + .updateDocumentContents( + any(OperationContext.class), + eq(UrnUtils.getUrn(TEST_ARTICLE_URN)), + any(), + eq(null), + eq(null), + any(Urn.class)); + } + + @Test + public void testUpdateContentsWithTitle() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + input.setTitle("New Title"); + + Boolean result = resolver.get(mockEnv).get(); + + assertTrue(result); + + // Verify title was passed to service + verify(mockService, times(1)) + .updateDocumentContents( + any(OperationContext.class), + any(Urn.class), + any(), + eq("New Title"), + eq(null), + any(Urn.class)); + } + + @Test + public void testUpdateContentsUnauthorized() throws Exception { + QueryContext mockContext = getMockDenyContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Verify service was NOT called + verify(mockService, times(0)) + .updateDocumentContents(any(OperationContext.class), any(), any(), any(), any(), any()); + } + + @Test + public void testUpdateContentsServiceThrowsException() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + doThrow(new RuntimeException("Service error")) + .when(mockService) + .updateDocumentContents(any(OperationContext.class), any(), any(), any(), any(), any()); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } + + @Test + public void testUpdateContentsWithSubType() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + input.setSubType("FAQ"); + + Boolean result = resolver.get(mockEnv).get(); + + assertTrue(result); + + // Verify subType was passed to service as a list + verify(mockService, times(1)) + .updateDocumentContents( + any(OperationContext.class), + any(Urn.class), + any(), + eq(null), + eq(java.util.Collections.singletonList("FAQ")), + any(Urn.class)); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentRelatedEntitiesResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentRelatedEntitiesResolverTest.java new file mode 100644 index 00000000000000..ae523e4c715a03 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentRelatedEntitiesResolverTest.java @@ -0,0 +1,123 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.UpdateDocumentRelatedEntitiesInput; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.CompletionException; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class UpdateDocumentRelatedEntitiesResolverTest { + + private static final String TEST_ARTICLE_URN = "urn:li:document:test-document"; + private static final String TEST_ASSET_URN = "urn:li:dataset:test-dataset"; + private static final String TEST_RELATED_ARTICLE_URN = "urn:li:document:related-document"; + + private DocumentService mockService; + private UpdateDocumentRelatedEntitiesResolver resolver; + private DataFetchingEnvironment mockEnv; + private UpdateDocumentRelatedEntitiesInput input; + + @BeforeMethod + public void setupTest() { + mockService = mock(DocumentService.class); + mockEnv = mock(DataFetchingEnvironment.class); + + // Setup default input + input = new UpdateDocumentRelatedEntitiesInput(); + input.setUrn(TEST_ARTICLE_URN); + input.setRelatedAssets(Arrays.asList(TEST_ASSET_URN)); + input.setRelatedDocuments(Arrays.asList(TEST_RELATED_ARTICLE_URN)); + + resolver = new UpdateDocumentRelatedEntitiesResolver(mockService); + } + + @Test + public void testUpdateRelatedEntitiesSuccess() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + Boolean result = resolver.get(mockEnv).get(); + + assertTrue(result); + + // Verify service was called + verify(mockService, times(1)) + .updateDocumentRelatedEntities( + any(OperationContext.class), + eq(UrnUtils.getUrn(TEST_ARTICLE_URN)), + any(), + any(), + any(Urn.class)); + } + + @Test + public void testUpdateOnlyRelatedAssets() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + input.setRelatedDocuments(null); // Only update assets + + Boolean result = resolver.get(mockEnv).get(); + + assertTrue(result); + + verify(mockService, times(1)) + .updateDocumentRelatedEntities( + any(OperationContext.class), any(), any(), eq(null), any(Urn.class)); + } + + @Test + public void testClearAllRelatedEntities() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + input.setRelatedAssets(Collections.emptyList()); + input.setRelatedDocuments(Collections.emptyList()); + + Boolean result = resolver.get(mockEnv).get(); + + assertTrue(result); + } + + @Test + public void testUpdateRelatedEntitiesUnauthorized() throws Exception { + QueryContext mockContext = getMockDenyContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Verify service was NOT called + verify(mockService, times(0)) + .updateDocumentRelatedEntities(any(OperationContext.class), any(), any(), any(), any()); + } + + @Test + public void testUpdateRelatedEntitiesServiceThrowsException() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + doThrow(new RuntimeException("Service error")) + .when(mockService) + .updateDocumentRelatedEntities(any(OperationContext.class), any(), any(), any(), any()); + + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentStatusResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentStatusResolverTest.java new file mode 100644 index 00000000000000..de4b66a49b0fe8 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/knowledge/UpdateDocumentStatusResolverTest.java @@ -0,0 +1,107 @@ +package com.linkedin.datahub.graphql.resolvers.knowledge; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.DocumentState; +import com.linkedin.datahub.graphql.generated.UpdateDocumentStatusInput; +import com.linkedin.metadata.service.DocumentService; +import graphql.schema.DataFetchingEnvironment; +import io.datahubproject.metadata.context.OperationContext; +import java.util.concurrent.CompletionException; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class UpdateDocumentStatusResolverTest { + + private static final String TEST_ARTICLE_URN = "urn:li:document:test-document-123"; + + private DocumentService mockService; + private DataFetchingEnvironment mockEnv; + private UpdateDocumentStatusResolver resolver; + + @BeforeMethod + public void setUp() { + mockService = mock(DocumentService.class); + mockEnv = mock(DataFetchingEnvironment.class); + + resolver = new UpdateDocumentStatusResolver(mockService); + } + + @Test + public void testConstructor() { + assertNotNull(resolver); + } + + @Test + public void testUpdateStatusSuccess() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + + // Setup input + UpdateDocumentStatusInput input = new UpdateDocumentStatusInput(); + input.setUrn(TEST_ARTICLE_URN); + input.setState(DocumentState.PUBLISHED); + + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + // Execute + Boolean result = resolver.get(mockEnv).get(); + + // Verify + assertTrue(result); + verify(mockService, times(1)) + .updateDocumentStatus( + any(OperationContext.class), + eq(UrnUtils.getUrn(TEST_ARTICLE_URN)), + eq(com.linkedin.knowledge.DocumentState.PUBLISHED), + any(Urn.class)); + } + + @Test + public void testUpdateStatusUnauthorized() throws Exception { + QueryContext mockContext = getMockDenyContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + + // Setup input + UpdateDocumentStatusInput input = new UpdateDocumentStatusInput(); + input.setUrn(TEST_ARTICLE_URN); + input.setState(DocumentState.UNPUBLISHED); + + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + // Execute and expect exception + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + + // Verify service was NOT called + verify(mockService, times(0)) + .updateDocumentStatus(any(OperationContext.class), any(), any(), any()); + } + + @Test + public void testUpdateStatusServiceException() throws Exception { + QueryContext mockContext = getMockAllowContext(); + when(mockEnv.getContext()).thenReturn(mockContext); + + // Setup input + UpdateDocumentStatusInput input = new UpdateDocumentStatusInput(); + input.setUrn(TEST_ARTICLE_URN); + input.setState(DocumentState.PUBLISHED); + + when(mockEnv.getArgument(eq("input"))).thenReturn(input); + + // Make service throw exception + doThrow(new RuntimeException("Service error")) + .when(mockService) + .updateDocumentStatus(any(OperationContext.class), any(Urn.class), any(), any(Urn.class)); + + // Execute and expect exception + assertThrows(CompletionException.class, () -> resolver.get(mockEnv).join()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/knowledge/DocumentMapperTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/knowledge/DocumentMapperTest.java new file mode 100644 index 00000000000000..60ba25a2b1206a --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/knowledge/DocumentMapperTest.java @@ -0,0 +1,406 @@ +package com.linkedin.datahub.graphql.types.knowledge; + +import static com.linkedin.metadata.Constants.*; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import com.linkedin.common.AuditStamp; +import com.linkedin.common.GlobalTags; +import com.linkedin.common.GlossaryTerms; +import com.linkedin.common.Ownership; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.generated.Document; +import com.linkedin.datahub.graphql.generated.DocumentSourceType; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.entity.Aspect; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.knowledge.DocumentContents; +import com.linkedin.knowledge.DocumentInfo; +import com.linkedin.knowledge.DocumentSource; +import com.linkedin.knowledge.ParentDocument; +import com.linkedin.knowledge.RelatedAsset; +import com.linkedin.knowledge.RelatedAssetArray; +import com.linkedin.knowledge.RelatedDocument; +import com.linkedin.knowledge.RelatedDocumentArray; +import com.linkedin.metadata.key.DocumentKey; +import com.linkedin.structured.StructuredProperties; +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.Map; +import org.mockito.MockedStatic; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class DocumentMapperTest { + + private static final String TEST_DOCUMENT_URN = "urn:li:document:test-document"; + private static final String TEST_DOCUMENT_ID = "test-document"; + private static final String TEST_DOCUMENT_TYPE = "tutorial"; + private static final String TEST_DOCUMENT_TITLE = "Test Tutorial"; + private static final String TEST_CONTENT = "Test content"; + private static final String TEST_ACTOR_URN = "urn:li:corpuser:testuser"; + private static final String TEST_PARENT_URN = "urn:li:document:parent-document"; + private static final String TEST_ASSET_URN = "urn:li:dataset:test-dataset"; + private static final String TEST_RELATED_DOCUMENT_URN = "urn:li:document:related-document"; + private static final Long TEST_TIMESTAMP = 1640995200000L; // 2022-01-01 00:00:00 UTC + + private Urn documentUrn; + private Urn actorUrn; + private Urn parentUrn; + private Urn assetUrn; + private Urn relatedDocumentUrn; + private QueryContext mockQueryContext; + + @BeforeMethod + public void setup() throws URISyntaxException { + documentUrn = Urn.createFromString(TEST_DOCUMENT_URN); + actorUrn = Urn.createFromString(TEST_ACTOR_URN); + parentUrn = Urn.createFromString(TEST_PARENT_URN); + assetUrn = Urn.createFromString(TEST_ASSET_URN); + relatedDocumentUrn = Urn.createFromString(TEST_RELATED_DOCUMENT_URN); + mockQueryContext = mock(QueryContext.class); + } + + @Test + public void testMapDocumentWithAllAspects() throws URISyntaxException { + // Setup entity response with all aspects + EntityResponse entityResponse = createBasicEntityResponse(); + + // Add document info + DocumentInfo documentInfo = new DocumentInfo(); + documentInfo.setTitle(TEST_DOCUMENT_TITLE); + + DocumentContents contents = new DocumentContents(); + contents.setText(TEST_CONTENT); + documentInfo.setContents(contents); + + AuditStamp createdStamp = new AuditStamp(); + createdStamp.setTime(TEST_TIMESTAMP); + createdStamp.setActor(actorUrn); + documentInfo.setCreated(createdStamp); + + AuditStamp lastModifiedStamp = new AuditStamp(); + lastModifiedStamp.setTime(TEST_TIMESTAMP); + lastModifiedStamp.setActor(actorUrn); + documentInfo.setLastModified(lastModifiedStamp); + + // Add custom properties + com.linkedin.data.template.StringMap customProperties = + new com.linkedin.data.template.StringMap(); + customProperties.put("key1", "value1"); + customProperties.put("key2", "value2"); + documentInfo.setCustomProperties(customProperties); + + addAspectToResponse(entityResponse, DOCUMENT_INFO_ASPECT_NAME, documentInfo); + + // Embed relationships inside DocumentInfo + ParentDocument parentDocument = new ParentDocument(); + parentDocument.setDocument(parentUrn); + documentInfo.setParentDocument(parentDocument); + + RelatedAsset relatedAsset = new RelatedAsset(); + relatedAsset.setAsset(assetUrn); + RelatedAssetArray assetsArray = new RelatedAssetArray(); + assetsArray.add(relatedAsset); + documentInfo.setRelatedAssets(assetsArray); + + RelatedDocument relatedDocument = new RelatedDocument(); + relatedDocument.setDocument(relatedDocumentUrn); + RelatedDocumentArray documentsArray = new RelatedDocumentArray(); + documentsArray.add(relatedDocument); + documentInfo.setRelatedDocuments(documentsArray); + + // Add ownership + Ownership ownership = new Ownership(); + ownership.setOwners(new com.linkedin.common.OwnerArray()); + addAspectToResponse(entityResponse, OWNERSHIP_ASPECT_NAME, ownership); + + // Add structured properties + StructuredProperties structuredProperties = new StructuredProperties(); + structuredProperties.setProperties( + new com.linkedin.structured.StructuredPropertyValueAssignmentArray()); + addAspectToResponse(entityResponse, STRUCTURED_PROPERTIES_ASPECT_NAME, structuredProperties); + + // Add global tags + GlobalTags globalTags = new GlobalTags(); + globalTags.setTags(new com.linkedin.common.TagAssociationArray()); + addAspectToResponse(entityResponse, GLOBAL_TAGS_ASPECT_NAME, globalTags); + + // Add glossary terms + GlossaryTerms glossaryTerms = new GlossaryTerms(); + glossaryTerms.setTerms(new com.linkedin.common.GlossaryTermAssociationArray()); + glossaryTerms.setAuditStamp(new AuditStamp().setTime(TEST_TIMESTAMP).setActor(actorUrn)); + addAspectToResponse(entityResponse, GLOSSARY_TERMS_ASPECT_NAME, glossaryTerms); + + // Mock authorization + try (MockedStatic authUtilsMock = mockStatic(AuthorizationUtils.class)) { + authUtilsMock.when(() -> AuthorizationUtils.canView(any(), eq(documentUrn))).thenReturn(true); + + // Execute mapping + Document result = DocumentMapper.map(mockQueryContext, entityResponse); + + // Verify results + assertNotNull(result); + assertEquals(result.getUrn(), TEST_DOCUMENT_URN); + assertEquals(result.getType(), EntityType.DOCUMENT); + + // Verify document info + assertNotNull(result.getInfo()); + assertEquals(result.getInfo().getTitle(), TEST_DOCUMENT_TITLE); + assertEquals(result.getInfo().getContents().getText(), TEST_CONTENT); + assertNotNull(result.getInfo().getCreated()); + assertEquals(result.getInfo().getCreated().getTime(), TEST_TIMESTAMP); + + // Relationships are present inside info and constructed as unresolved stubs + assertNotNull(result.getInfo().getParentDocument()); + assertNotNull(result.getInfo().getRelatedAssets()); + assertNotNull(result.getInfo().getRelatedDocuments()); + + // Verify other aspects + assertNotNull(result.getOwnership()); + assertNotNull(result.getStructuredProperties()); + assertNotNull(result.getTags()); + assertNotNull(result.getGlossaryTerms()); + + // Verify custom properties + assertNotNull(result.getInfo().getCustomProperties()); + assertEquals(result.getInfo().getCustomProperties().size(), 2); + assertEquals(result.getInfo().getCustomProperties().get(0).getKey(), "key1"); + assertEquals(result.getInfo().getCustomProperties().get(0).getValue(), "value1"); + } + } + + @Test + public void testMapDocumentWithOnlyKeyAndInfo() throws URISyntaxException { + // Setup entity response with only key and info + EntityResponse entityResponse = createBasicEntityResponse(); + + // Add minimal document info + DocumentInfo documentInfo = new DocumentInfo(); + + DocumentContents contents = new DocumentContents(); + contents.setText(TEST_CONTENT); + documentInfo.setContents(contents); + + AuditStamp createdStamp = new AuditStamp(); + createdStamp.setTime(TEST_TIMESTAMP); + createdStamp.setActor(actorUrn); + documentInfo.setCreated(createdStamp); + + AuditStamp lastModifiedStamp = new AuditStamp(); + lastModifiedStamp.setTime(TEST_TIMESTAMP); + lastModifiedStamp.setActor(actorUrn); + documentInfo.setLastModified(lastModifiedStamp); + + addAspectToResponse(entityResponse, DOCUMENT_INFO_ASPECT_NAME, documentInfo); + + // Mock authorization + try (MockedStatic authUtilsMock = mockStatic(AuthorizationUtils.class)) { + authUtilsMock.when(() -> AuthorizationUtils.canView(any(), eq(documentUrn))).thenReturn(true); + + // Execute mapping + Document result = DocumentMapper.map(mockQueryContext, entityResponse); + + // Verify results + assertNotNull(result); + assertEquals(result.getUrn(), TEST_DOCUMENT_URN); + assertEquals(result.getType(), EntityType.DOCUMENT); + + // Verify document info + assertNotNull(result.getInfo()); + assertNull(result.getInfo().getTitle()); // title was not set + + // Verify optional relationships are null when not provided + assertNull(result.getInfo().getParentDocument()); + assertNull(result.getInfo().getRelatedAssets()); + assertNull(result.getInfo().getRelatedDocuments()); + assertNull(result.getOwnership()); + assertNull(result.getStructuredProperties()); + assertNull(result.getTags()); + assertNull(result.getGlossaryTerms()); + } + } + + @Test + public void testMapDocumentWithoutKey() { + // Setup entity response without key aspect + EntityResponse entityResponse = new EntityResponse(); + entityResponse.setUrn(documentUrn); + entityResponse.setAspects(new EnvelopedAspectMap()); + + // Mock authorization + try (MockedStatic authUtilsMock = mockStatic(AuthorizationUtils.class)) { + authUtilsMock.when(() -> AuthorizationUtils.canView(any(), eq(documentUrn))).thenReturn(true); + + // Execute mapping + Document result = DocumentMapper.map(mockQueryContext, entityResponse); + + // Should return entity with just urn and type when key aspect is missing + assertNotNull(result); + assertEquals(result.getUrn(), TEST_DOCUMENT_URN); + assertEquals(result.getType(), EntityType.DOCUMENT); + } + } + + @Test + public void testMapDocumentWithRestrictedAccess() { + // Setup entity response + EntityResponse entityResponse = createBasicEntityResponse(); + + // Mock authorization to deny access + try (MockedStatic authUtilsMock = mockStatic(AuthorizationUtils.class)) { + authUtilsMock + .when(() -> AuthorizationUtils.canView(any(), eq(documentUrn))) + .thenReturn(false); + + Document restrictedDocument = new Document(); + authUtilsMock + .when(() -> AuthorizationUtils.restrictEntity(any(Document.class), eq(Document.class))) + .thenReturn(restrictedDocument); + + // Execute mapping + Document result = DocumentMapper.map(mockQueryContext, entityResponse); + + // Should return restricted entity + assertEquals(result, restrictedDocument); + + // Verify authorization calls + authUtilsMock.verify(() -> AuthorizationUtils.canView(any(), eq(documentUrn))); + authUtilsMock.verify( + () -> AuthorizationUtils.restrictEntity(any(Document.class), eq(Document.class))); + } + } + + @Test + public void testMapDocumentWithNullQueryContext() { + // Setup entity response + EntityResponse entityResponse = createBasicEntityResponse(); + + // Execute mapping with null query context + Document result = DocumentMapper.map(null, entityResponse); + + // Should return document without authorization checks + assertNotNull(result); + assertEquals(result.getUrn(), TEST_DOCUMENT_URN); + assertEquals(result.getType(), EntityType.DOCUMENT); + } + + @Test + public void testMapDocumentSourceNative() throws URISyntaxException { + // Setup entity response with NATIVE source + EntityResponse entityResponse = createBasicEntityResponse(); + + // Add document info with NATIVE source + DocumentInfo documentInfo = new DocumentInfo(); + + DocumentContents contents = new DocumentContents(); + contents.setText(TEST_CONTENT); + documentInfo.setContents(contents); + + AuditStamp createdStamp = new AuditStamp(); + createdStamp.setTime(TEST_TIMESTAMP); + createdStamp.setActor(actorUrn); + documentInfo.setCreated(createdStamp); + + AuditStamp lastModifiedStamp = new AuditStamp(); + lastModifiedStamp.setTime(TEST_TIMESTAMP); + lastModifiedStamp.setActor(actorUrn); + documentInfo.setLastModified(lastModifiedStamp); + + // Add NATIVE source + DocumentSource source = new DocumentSource(); + source.setSourceType(com.linkedin.knowledge.DocumentSourceType.NATIVE); + documentInfo.setSource(source); + + addAspectToResponse(entityResponse, DOCUMENT_INFO_ASPECT_NAME, documentInfo); + + // Mock authorization + try (MockedStatic authUtilsMock = mockStatic(AuthorizationUtils.class)) { + authUtilsMock.when(() -> AuthorizationUtils.canView(any(), eq(documentUrn))).thenReturn(true); + + // Execute mapping + Document result = DocumentMapper.map(mockQueryContext, entityResponse); + + // Verify source is mapped correctly + assertNotNull(result.getInfo().getSource()); + assertEquals(result.getInfo().getSource().getSourceType(), DocumentSourceType.NATIVE); + } + } + + @Test + public void testMapDocumentSourceExternal() throws URISyntaxException { + // Setup entity response with EXTERNAL source + EntityResponse entityResponse = createBasicEntityResponse(); + + // Add document info with EXTERNAL source + DocumentInfo documentInfo = new DocumentInfo(); + + DocumentContents contents = new DocumentContents(); + contents.setText(TEST_CONTENT); + documentInfo.setContents(contents); + + AuditStamp createdStamp = new AuditStamp(); + createdStamp.setTime(TEST_TIMESTAMP); + createdStamp.setActor(actorUrn); + documentInfo.setCreated(createdStamp); + + AuditStamp lastModifiedStamp = new AuditStamp(); + lastModifiedStamp.setTime(TEST_TIMESTAMP); + lastModifiedStamp.setActor(actorUrn); + documentInfo.setLastModified(lastModifiedStamp); + + // Add EXTERNAL source with additional fields + DocumentSource source = new DocumentSource(); + source.setSourceType(com.linkedin.knowledge.DocumentSourceType.EXTERNAL); + source.setExternalUrl("https://external.com/doc"); + source.setExternalId("ext-123"); + documentInfo.setSource(source); + + addAspectToResponse(entityResponse, DOCUMENT_INFO_ASPECT_NAME, documentInfo); + + // Mock authorization + try (MockedStatic authUtilsMock = mockStatic(AuthorizationUtils.class)) { + authUtilsMock.when(() -> AuthorizationUtils.canView(any(), eq(documentUrn))).thenReturn(true); + + // Execute mapping + Document result = DocumentMapper.map(mockQueryContext, entityResponse); + + // Verify source is mapped correctly + assertNotNull(result.getInfo().getSource()); + assertEquals(result.getInfo().getSource().getSourceType(), DocumentSourceType.EXTERNAL); + assertEquals(result.getInfo().getSource().getExternalUrl(), "https://external.com/doc"); + assertEquals(result.getInfo().getSource().getExternalId(), "ext-123"); + } + } + + // Helper methods + + private EntityResponse createBasicEntityResponse() { + EntityResponse entityResponse = new EntityResponse(); + entityResponse.setUrn(documentUrn); + + // Create document key aspect + DocumentKey documentKey = new DocumentKey(); + documentKey.setId(TEST_DOCUMENT_ID); + + EnvelopedAspect keyAspect = new EnvelopedAspect(); + keyAspect.setValue(new Aspect(documentKey.data())); + + Map aspects = new HashMap<>(); + aspects.put(DOCUMENT_KEY_ASPECT_NAME, keyAspect); + + entityResponse.setAspects(new EnvelopedAspectMap(aspects)); + return entityResponse; + } + + private void addAspectToResponse( + EntityResponse entityResponse, String aspectName, Object aspectData) { + EnvelopedAspect aspect = new EnvelopedAspect(); + aspect.setValue(new Aspect(((com.linkedin.data.template.RecordTemplate) aspectData).data())); + entityResponse.getAspects().put(aspectName, aspect); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/knowledge/DocumentTypeTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/knowledge/DocumentTypeTest.java new file mode 100644 index 00000000000000..bfdd2244d3fbe1 --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/knowledge/DocumentTypeTest.java @@ -0,0 +1,167 @@ +package com.linkedin.datahub.graphql.types.knowledge; + +import static com.linkedin.datahub.graphql.TestUtils.*; +import static org.mockito.ArgumentMatchers.any; +import static org.testng.Assert.*; + +import com.datahub.authentication.Authentication; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.Owner; +import com.linkedin.common.OwnerArray; +import com.linkedin.common.Ownership; +import com.linkedin.common.OwnershipType; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.Document; +import com.linkedin.datahub.graphql.generated.EntityType; +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.knowledge.DocumentContents; +import com.linkedin.knowledge.DocumentInfo; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.key.DocumentKey; +import com.linkedin.r2.RemoteInvocationException; +import graphql.execution.DataFetcherResult; +import java.util.HashSet; +import java.util.List; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class DocumentTypeTest { + + private static final String TEST_DOCUMENT_1_URN = "urn:li:document:document-1"; + private static final DocumentKey TEST_DOCUMENT_1_KEY = new DocumentKey().setId("document-1"); + + private static final DocumentInfo TEST_DOCUMENT_1_INFO = createTestDocumentInfo(); + + private static final Ownership TEST_DOCUMENT_1_OWNERSHIP = + new Ownership() + .setOwners( + new OwnerArray( + ImmutableList.of( + new Owner() + .setType(OwnershipType.DATAOWNER) + .setOwner(Urn.createFromTuple("corpuser", "test"))))); + + private static final String TEST_DOCUMENT_2_URN = "urn:li:document:document-2"; + + private static DocumentInfo createTestDocumentInfo() { + DocumentInfo info = new DocumentInfo(); + // Type is now stored in subTypes aspect, not in info + info.setTitle("Test Tutorial"); + + DocumentContents contents = new DocumentContents(); + contents.setText("Test content"); + info.setContents(contents); + + AuditStamp createdStamp = new AuditStamp(); + createdStamp.setTime(1640995200000L); + createdStamp.setActor(Urn.createFromTuple("corpuser", "testuser")); + info.setCreated(createdStamp); + + AuditStamp lastModifiedStamp = new AuditStamp(); + lastModifiedStamp.setTime(1640995200000L); + lastModifiedStamp.setActor(Urn.createFromTuple("corpuser", "testuser")); + info.setLastModified(lastModifiedStamp); + + return info; + } + + @Test + public void testBatchLoad() throws Exception { + + EntityClient client = Mockito.mock(EntityClient.class); + + Urn documentUrn1 = Urn.createFromString(TEST_DOCUMENT_1_URN); + Urn documentUrn2 = Urn.createFromString(TEST_DOCUMENT_2_URN); + + Mockito.when( + client.batchGetV2( + any(), + Mockito.eq(Constants.DOCUMENT_ENTITY_NAME), + Mockito.eq(new HashSet<>(ImmutableSet.of(documentUrn1, documentUrn2))), + Mockito.eq(DocumentType.ASPECTS_TO_FETCH))) + .thenReturn( + ImmutableMap.of( + documentUrn1, + new EntityResponse() + .setEntityName(Constants.DOCUMENT_ENTITY_NAME) + .setUrn(documentUrn1) + .setAspects( + new EnvelopedAspectMap( + ImmutableMap.of( + Constants.DOCUMENT_KEY_ASPECT_NAME, + new EnvelopedAspect() + .setValue(new Aspect(TEST_DOCUMENT_1_KEY.data())), + Constants.DOCUMENT_INFO_ASPECT_NAME, + new EnvelopedAspect() + .setValue(new Aspect(TEST_DOCUMENT_1_INFO.data())), + Constants.OWNERSHIP_ASPECT_NAME, + new EnvelopedAspect() + .setValue(new Aspect(TEST_DOCUMENT_1_OWNERSHIP.data()))))))); + + DocumentType type = new DocumentType(client); + + QueryContext mockContext = getMockAllowContext(); + List> result = + type.batchLoad(ImmutableList.of(TEST_DOCUMENT_1_URN, TEST_DOCUMENT_2_URN), mockContext); + + // Verify response + Mockito.verify(client, Mockito.times(1)) + .batchGetV2( + any(), + Mockito.eq(Constants.DOCUMENT_ENTITY_NAME), + Mockito.eq(ImmutableSet.of(documentUrn1, documentUrn2)), + Mockito.eq(DocumentType.ASPECTS_TO_FETCH)); + + assertEquals(result.size(), 2); + + Document document1 = result.get(0).getData(); + assertEquals(document1.getUrn(), TEST_DOCUMENT_1_URN); + assertEquals(document1.getType(), EntityType.DOCUMENT); + assertEquals(document1.getOwnership().getOwners().size(), 1); + assertEquals(document1.getInfo().getTitle(), "Test Tutorial"); + assertEquals(document1.getInfo().getContents().getText(), "Test content"); + + // Assert second element is null. + assertNull(result.get(1)); + } + + @Test + public void testBatchLoadClientException() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + Mockito.doThrow(RemoteInvocationException.class) + .when(mockClient) + .batchGetV2(any(), Mockito.anyString(), Mockito.anySet(), Mockito.anySet()); + DocumentType type = new DocumentType(mockClient); + + // Execute Batch load + QueryContext context = Mockito.mock(QueryContext.class); + Mockito.when(context.getAuthentication()).thenReturn(Mockito.mock(Authentication.class)); + assertThrows( + RuntimeException.class, + () -> type.batchLoad(ImmutableList.of(TEST_DOCUMENT_1_URN, TEST_DOCUMENT_2_URN), context)); + } + + @Test + public void testEntityType() { + EntityClient mockClient = Mockito.mock(EntityClient.class); + DocumentType type = new DocumentType(mockClient); + + assertEquals(type.type(), EntityType.DOCUMENT); + } + + @Test + public void testObjectClass() { + EntityClient mockClient = Mockito.mock(EntityClient.class); + DocumentType type = new DocumentType(mockClient); + + assertEquals(type.objectClass(), Document.class); + } +} diff --git a/docs/managed-datahub/managed-datahub-overview.md b/docs/managed-datahub/managed-datahub-overview.md index 276a52ee3dc67c..3bac6cfba13560 100644 --- a/docs/managed-datahub/managed-datahub-overview.md +++ b/docs/managed-datahub/managed-datahub-overview.md @@ -86,7 +86,7 @@ Features aimed at making it easy to discover data assets at your organization an | Subscribe to assets, activity, and notifications | ❌ | ✅ | | Email, Slack, & Microsoft Teams notifications | ❌ | ✅ | | Customizable Home Page and Asset Summaries | ❌ | ✅ **(beta)** | -| **Ask DataHub** - AI assistant in Slack & Microsoft Teams | ❌ | ✅ **(beta)** | +| **Ask DataHub** - AI assistant | ❌ | ✅ **(beta)** | | Invite Users via Email & User Invite Recommendations | ❌ | ✅ | ## Data Observability diff --git a/li-utils/src/main/java/com/linkedin/metadata/Constants.java b/li-utils/src/main/java/com/linkedin/metadata/Constants.java index c01079e2133f07..e92e4f98aa5559 100644 --- a/li-utils/src/main/java/com/linkedin/metadata/Constants.java +++ b/li-utils/src/main/java/com/linkedin/metadata/Constants.java @@ -116,6 +116,7 @@ public class Constants { public static final String RESTRICTED_ENTITY_NAME = "restricted"; public static final String BUSINESS_ATTRIBUTE_ENTITY_NAME = "businessAttribute"; public static final String PLATFORM_RESOURCE_ENTITY_NAME = "platformResource"; + public static final String DOCUMENT_ENTITY_NAME = "document"; /** Aspects */ // Common @@ -461,6 +462,11 @@ public class Constants { public static final String BUSINESS_ATTRIBUTE_INFO_ASPECT_NAME = "businessAttributeInfo"; public static final String BUSINESS_ATTRIBUTE_ASSOCIATION = "businessAttributeAssociation"; public static final String BUSINESS_ATTRIBUTE_ASPECT = "businessAttributes"; + + // Knowledge Article + public static final String DOCUMENT_KEY_ASPECT_NAME = "documentKey"; + public static final String DOCUMENT_INFO_ASPECT_NAME = "documentInfo"; + public static final List SKIP_REFERENCE_ASPECT = Arrays.asList("ownership", "status", "institutionalMemory"); diff --git a/metadata-io/src/main/java/com/linkedin/metadata/timeline/TimelineServiceImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/timeline/TimelineServiceImpl.java index 5961a97ecfec78..d735399894648d 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/timeline/TimelineServiceImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/timeline/TimelineServiceImpl.java @@ -16,6 +16,7 @@ import com.linkedin.metadata.timeline.data.ChangeTransaction; import com.linkedin.metadata.timeline.data.SemanticChangeType; import com.linkedin.metadata.timeline.eventgenerator.DatasetPropertiesChangeEventGenerator; +import com.linkedin.metadata.timeline.eventgenerator.DocumentInfoChangeEventGenerator; import com.linkedin.metadata.timeline.eventgenerator.EditableDatasetPropertiesChangeEventGenerator; import com.linkedin.metadata.timeline.eventgenerator.EditableSchemaMetadataChangeEventGenerator; import com.linkedin.metadata.timeline.eventgenerator.EntityChangeEventGenerator; @@ -214,9 +215,35 @@ public TimelineServiceImpl(@Nonnull AspectDao aspectDao, @Nonnull EntityRegistry } glossaryTermElementAspectRegistry.put(elementName, aspects); } + + // Document registry + HashMap> documentElementAspectRegistry = new HashMap<>(); + String entityTypeDocument = DOCUMENT_ENTITY_NAME; + for (ChangeCategory elementName : ChangeCategory.values()) { + Set aspects = new HashSet<>(); + switch (elementName) { + case LIFECYCLE: + case DOCUMENTATION: + case TAG: + { + // DocumentInfo handles all these categories + aspects.add(DOCUMENT_INFO_ASPECT_NAME); + _entityChangeEventGeneratorFactory.addGenerator( + entityTypeDocument, + elementName, + DOCUMENT_INFO_ASPECT_NAME, + new DocumentInfoChangeEventGenerator()); + } + break; + default: + break; + } + documentElementAspectRegistry.put(elementName, aspects); + } entityTypeElementAspectRegistry.put(DATASET_ENTITY_NAME, datasetElementAspectRegistry); entityTypeElementAspectRegistry.put( GLOSSARY_TERM_ENTITY_NAME, glossaryTermElementAspectRegistry); + entityTypeElementAspectRegistry.put(DOCUMENT_ENTITY_NAME, documentElementAspectRegistry); } Set getAspectsFromElements(String entityType, Set elementNames) { diff --git a/metadata-io/src/main/java/com/linkedin/metadata/timeline/eventgenerator/DocumentInfoChangeEventGenerator.java b/metadata-io/src/main/java/com/linkedin/metadata/timeline/eventgenerator/DocumentInfoChangeEventGenerator.java new file mode 100644 index 00000000000000..f86af8ce43ee41 --- /dev/null +++ b/metadata-io/src/main/java/com/linkedin/metadata/timeline/eventgenerator/DocumentInfoChangeEventGenerator.java @@ -0,0 +1,368 @@ +package com.linkedin.metadata.timeline.eventgenerator; + +import com.datahub.util.RecordUtils; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; +import com.linkedin.knowledge.DocumentInfo; +import com.linkedin.metadata.aspect.EntityAspect; +import com.linkedin.metadata.timeline.data.ChangeCategory; +import com.linkedin.metadata.timeline.data.ChangeEvent; +import com.linkedin.metadata.timeline.data.ChangeOperation; +import com.linkedin.metadata.timeline.data.ChangeTransaction; +import com.linkedin.metadata.timeline.data.SemanticChangeType; +import jakarta.json.JsonPatch; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; + +/** + * Generates change events for DocumentInfo aspect. Tracks changes to: - Document creation - + * Content/title modifications - Parent document changes - Related entities (assets and documents) - + * Document state changes + */ +@Slf4j +public class DocumentInfoChangeEventGenerator extends EntityChangeEventGenerator { + + private static final String CONTENT_CATEGORY = "DOCUMENTATION"; + private static final String PARENT_CATEGORY = "PARENT_DOCUMENT"; + private static final String RELATED_ENTITIES_CATEGORY = "RELATED_ENTITIES"; + private static final String STATE_CATEGORY = "STATUS"; + private static final String LIFECYCLE_CATEGORY = "LIFECYCLE"; + + @Override + public List getChangeEvents( + @Nonnull Urn urn, + @Nonnull String entity, + @Nonnull String aspect, + @Nonnull Aspect from, + @Nonnull Aspect to, + @Nonnull AuditStamp auditStamp) { + // This method is required by the interface but not used in our implementation. + // We use getSemanticDiff instead which is called by the Timeline Service. + return new ArrayList<>(); + } + + @Override + public ChangeTransaction getSemanticDiff( + @Nullable EntityAspect previousValue, + @Nonnull EntityAspect currentValue, + @Nonnull ChangeCategory changeCategory, + @Nullable JsonPatch rawDiff, + boolean rawDiffsRequested) { + + if (previousValue == null || previousValue.getVersion() == -1) { + // Document creation + return getChangeTransactionForCreation(currentValue); + } + + List changeEvents = new ArrayList<>(); + DocumentInfo oldDoc = getDocumentInfoFromAspect(previousValue); + DocumentInfo newDoc = getDocumentInfoFromAspect(currentValue); + AuditStamp auditStamp = getAuditStamp(currentValue); + + if (oldDoc == null || newDoc == null) { + log.warn( + "Failed to deserialize DocumentInfo aspect for entity {}, skipping diff", + currentValue.getUrn()); + return ChangeTransaction.builder() + .changeEvents(changeEvents) + .timestamp(currentValue.getCreatedOn().getTime()) + .semVerChange(SemanticChangeType.NONE) + .build(); + } + + String entityUrn = currentValue.getUrn(); + + // Check content/title changes + if (shouldCheckCategory(changeCategory, CONTENT_CATEGORY)) { + addContentChanges(oldDoc, newDoc, entityUrn, auditStamp, changeEvents); + } + + // Check parent document changes + if (shouldCheckCategory(changeCategory, PARENT_CATEGORY)) { + addParentChanges(oldDoc, newDoc, entityUrn, auditStamp, changeEvents); + } + + // Check related entities changes + if (shouldCheckCategory(changeCategory, RELATED_ENTITIES_CATEGORY)) { + addRelatedEntitiesChanges(oldDoc, newDoc, entityUrn, auditStamp, changeEvents); + } + + // Check state changes + if (shouldCheckCategory(changeCategory, STATE_CATEGORY)) { + addStateChanges(oldDoc, newDoc, entityUrn, auditStamp, changeEvents); + } + + return ChangeTransaction.builder() + .changeEvents(changeEvents) + .timestamp(currentValue.getCreatedOn().getTime()) + .semVerChange(SemanticChangeType.NONE) + .build(); + } + + private ChangeTransaction getChangeTransactionForCreation(@Nonnull EntityAspect currentValue) { + DocumentInfo doc = getDocumentInfoFromAspect(currentValue); + AuditStamp auditStamp = getAuditStamp(currentValue); + if (doc == null) { + return ChangeTransaction.builder() + .changeEvents(new ArrayList<>()) + .timestamp(currentValue.getCreatedOn().getTime()) + .semVerChange(SemanticChangeType.NONE) + .build(); + } + String description = + String.format("Document '%s' was created", doc.hasTitle() ? doc.getTitle() : "Untitled"); + + ChangeEvent createEvent = + ChangeEvent.builder() + .category(ChangeCategory.LIFECYCLE) + .operation(ChangeOperation.CREATE) + .entityUrn(currentValue.getUrn()) + .auditStamp(auditStamp) + .description(description) + .build(); + + List events = new ArrayList<>(); + events.add(createEvent); + + return ChangeTransaction.builder() + .changeEvents(events) + .timestamp(currentValue.getCreatedOn().getTime()) + .semVerChange(SemanticChangeType.NONE) + .build(); + } + + private void addContentChanges( + DocumentInfo oldDoc, + DocumentInfo newDoc, + String entityUrn, + AuditStamp auditStamp, + List events) { + + // Check title change + String oldTitle = oldDoc.hasTitle() ? oldDoc.getTitle() : null; + String newTitle = newDoc.hasTitle() ? newDoc.getTitle() : null; + if (!Objects.equals(oldTitle, newTitle)) { + String description = + String.format("Document title changed from '%s' to '%s'", oldTitle, newTitle); + events.add( + ChangeEvent.builder() + .category(ChangeCategory.DOCUMENTATION) + .operation(ChangeOperation.MODIFY) + .entityUrn(entityUrn) + .auditStamp(auditStamp) + .description(description) + .parameters( + Map.of( + "oldTitle", + oldTitle != null ? oldTitle : "", + "newTitle", + newTitle != null ? newTitle : "")) + .build()); + } + + // Check content change + String oldContent = oldDoc.hasContents() ? oldDoc.getContents().getText() : null; + String newContent = newDoc.hasContents() ? newDoc.getContents().getText() : null; + if (!Objects.equals(oldContent, newContent)) { + String description = "Document content was modified"; + events.add( + ChangeEvent.builder() + .category(ChangeCategory.DOCUMENTATION) + .operation(ChangeOperation.MODIFY) + .entityUrn(entityUrn) + .auditStamp(auditStamp) + .description(description) + .build()); + } + } + + private void addParentChanges( + DocumentInfo oldDoc, + DocumentInfo newDoc, + String entityUrn, + AuditStamp auditStamp, + List events) { + + Urn oldParent = oldDoc.hasParentDocument() ? oldDoc.getParentDocument().getDocument() : null; + Urn newParent = newDoc.hasParentDocument() ? newDoc.getParentDocument().getDocument() : null; + + if (!Objects.equals(oldParent, newParent)) { + String description; + Map params = new HashMap<>(); + + if (oldParent == null && newParent != null) { + description = String.format("Document moved to parent %s", newParent); + params.put("newParent", newParent.toString()); + } else if (oldParent != null && newParent == null) { + description = "Document moved to root level (no parent)"; + params.put("oldParent", oldParent.toString()); + } else { + description = String.format("Document moved from %s to %s", oldParent, newParent); + params.put("oldParent", oldParent != null ? oldParent.toString() : ""); + params.put("newParent", newParent != null ? newParent.toString() : ""); + } + + events.add( + ChangeEvent.builder() + .category(ChangeCategory.TAG) // Using TAG as a proxy for PARENT_DOCUMENT + .operation(ChangeOperation.MODIFY) + .entityUrn(entityUrn) + .auditStamp(auditStamp) + .description(description) + .parameters(params) + .build()); + } + } + + private void addRelatedEntitiesChanges( + DocumentInfo oldDoc, + DocumentInfo newDoc, + String entityUrn, + AuditStamp auditStamp, + List events) { + + // Check related assets changes + List oldAssets = + oldDoc.hasRelatedAssets() + ? oldDoc.getRelatedAssets().stream() + .map(asset -> asset.getAsset()) + .collect(Collectors.toList()) + : new ArrayList<>(); + List newAssets = + newDoc.hasRelatedAssets() + ? newDoc.getRelatedAssets().stream() + .map(asset -> asset.getAsset()) + .collect(Collectors.toList()) + : new ArrayList<>(); + + addRelationshipChanges(oldAssets, newAssets, "asset", "assets", entityUrn, auditStamp, events); + + // Check related documents changes + List oldDocs = + oldDoc.hasRelatedDocuments() + ? oldDoc.getRelatedDocuments().stream() + .map(doc -> doc.getDocument()) + .collect(Collectors.toList()) + : new ArrayList<>(); + List newDocs = + newDoc.hasRelatedDocuments() + ? newDoc.getRelatedDocuments().stream() + .map(doc -> doc.getDocument()) + .collect(Collectors.toList()) + : new ArrayList<>(); + + addRelationshipChanges( + oldDocs, newDocs, "document", "documents", entityUrn, auditStamp, events); + } + + private void addRelationshipChanges( + List oldUrns, + List newUrns, + String singular, + String plural, + String entityUrn, + AuditStamp auditStamp, + List events) { + + List added = new ArrayList<>(newUrns); + added.removeAll(oldUrns); + + List removed = new ArrayList<>(oldUrns); + removed.removeAll(newUrns); + + for (Urn urn : added) { + events.add( + ChangeEvent.builder() + .category(ChangeCategory.TAG) // Using TAG as proxy for related entities + .operation(ChangeOperation.ADD) + .entityUrn(entityUrn) + .modifier(urn.toString()) + .auditStamp(auditStamp) + .description(String.format("Related %s %s was added", singular, urn)) + .build()); + } + + for (Urn urn : removed) { + events.add( + ChangeEvent.builder() + .category(ChangeCategory.TAG) // Using TAG as proxy for related entities + .operation(ChangeOperation.REMOVE) + .entityUrn(entityUrn) + .modifier(urn.toString()) + .auditStamp(auditStamp) + .description(String.format("Related %s %s was removed", singular, urn)) + .build()); + } + } + + private void addStateChanges( + DocumentInfo oldDoc, + DocumentInfo newDoc, + String entityUrn, + AuditStamp auditStamp, + List events) { + + String oldState = oldDoc.hasStatus() ? oldDoc.getStatus().getState().name() : null; + String newState = newDoc.hasStatus() ? newDoc.getStatus().getState().name() : null; + + if (!Objects.equals(oldState, newState)) { + String description = + String.format("Document state changed from %s to %s", oldState, newState); + events.add( + ChangeEvent.builder() + .category(ChangeCategory.LIFECYCLE) + .operation(ChangeOperation.MODIFY) + .entityUrn(entityUrn) + .auditStamp(auditStamp) + .description(description) + .parameters( + Map.of( + "oldState", + oldState != null ? oldState : "", + "newState", + newState != null ? newState : "")) + .build()); + } + } + + @Nullable + private DocumentInfo getDocumentInfoFromAspect(@Nonnull EntityAspect aspect) { + try { + return RecordUtils.toRecordTemplate(DocumentInfo.class, aspect.getMetadata()); + } catch (Exception e) { + log.error("Failed to deserialize DocumentInfo from aspect", e); + return null; + } + } + + private AuditStamp getAuditStamp(@Nonnull EntityAspect aspect) { + AuditStamp auditStamp = new AuditStamp(); + auditStamp.setTime(aspect.getCreatedOn().getTime()); + try { + auditStamp.setActor(Urn.createFromString(aspect.getCreatedBy())); + } catch (Exception e) { + log.warn("Failed to parse actor URN: {}", aspect.getCreatedBy()); + } + return auditStamp; + } + + private boolean shouldCheckCategory(ChangeCategory requested, String categoryName) { + // If requested is null, check all categories + if (requested == null) { + return true; + } + // Map our custom categories to standard ones + return requested.name().equals(categoryName) + || (categoryName.equals(CONTENT_CATEGORY) && requested == ChangeCategory.DOCUMENTATION) + || (categoryName.equals(STATE_CATEGORY) && requested == ChangeCategory.LIFECYCLE) + || (categoryName.equals(PARENT_CATEGORY) && requested == ChangeCategory.TAG) + || (categoryName.equals(RELATED_ENTITIES_CATEGORY) && requested == ChangeCategory.TAG); + } +} diff --git a/metadata-io/src/test/java/com/linkedin/metadata/timeline/eventgenerator/DocumentInfoChangeEventGeneratorTest.java b/metadata-io/src/test/java/com/linkedin/metadata/timeline/eventgenerator/DocumentInfoChangeEventGeneratorTest.java new file mode 100644 index 00000000000000..1f72e3741a9741 --- /dev/null +++ b/metadata-io/src/test/java/com/linkedin/metadata/timeline/eventgenerator/DocumentInfoChangeEventGeneratorTest.java @@ -0,0 +1,343 @@ +package com.linkedin.metadata.timeline.eventgenerator; + +import static org.testng.Assert.*; + +import com.datahub.util.RecordUtils; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.knowledge.DocumentContents; +import com.linkedin.knowledge.DocumentInfo; +import com.linkedin.knowledge.DocumentState; +import com.linkedin.knowledge.DocumentStatus; +import com.linkedin.knowledge.ParentDocument; +import com.linkedin.knowledge.RelatedAsset; +import com.linkedin.knowledge.RelatedAssetArray; +import com.linkedin.knowledge.RelatedDocument; +import com.linkedin.knowledge.RelatedDocumentArray; +import com.linkedin.metadata.aspect.EntityAspect; +import com.linkedin.metadata.timeline.data.ChangeCategory; +import com.linkedin.metadata.timeline.data.ChangeEvent; +import com.linkedin.metadata.timeline.data.ChangeOperation; +import com.linkedin.metadata.timeline.data.ChangeTransaction; +import java.sql.Timestamp; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class DocumentInfoChangeEventGeneratorTest { + + private static final String TEST_URN = "urn:li:document:test-doc"; + private static final String TEST_USER = "urn:li:corpuser:testUser"; + private static final long TEST_TIME = 1234567890000L; + + private DocumentInfoChangeEventGenerator generator; + + @BeforeMethod + public void setup() { + generator = new DocumentInfoChangeEventGenerator(); + } + + @Test + public void testDocumentCreation() throws Exception { + // Setup - no previous version + EntityAspect previousAspect = null; + EntityAspect currentAspect = createEntityAspect(createDocumentInfo("Test Document", "Content")); + + // Execute + ChangeTransaction transaction = + generator.getSemanticDiff( + previousAspect, currentAspect, ChangeCategory.LIFECYCLE, null, false); + + // Verify + assertNotNull(transaction); + assertNotNull(transaction.getChangeEvents()); + assertEquals(transaction.getChangeEvents().size(), 1); + + ChangeEvent event = transaction.getChangeEvents().get(0); + assertEquals(event.getCategory(), ChangeCategory.LIFECYCLE); + assertEquals(event.getOperation(), ChangeOperation.CREATE); + assertTrue(event.getDescription().contains("Test Document")); + assertTrue(event.getDescription().contains("created")); + } + + @Test + public void testTitleChange() throws Exception { + // Setup + DocumentInfo oldDoc = createDocumentInfo("Old Title", "Content"); + DocumentInfo newDoc = createDocumentInfo("New Title", "Content"); + + EntityAspect previousAspect = createEntityAspect(oldDoc); + EntityAspect currentAspect = createEntityAspect(newDoc); + + // Execute + ChangeTransaction transaction = + generator.getSemanticDiff( + previousAspect, currentAspect, ChangeCategory.DOCUMENTATION, null, false); + + // Verify + assertNotNull(transaction); + assertNotNull(transaction.getChangeEvents()); + assertEquals(transaction.getChangeEvents().size(), 1); + + ChangeEvent event = transaction.getChangeEvents().get(0); + assertEquals(event.getCategory(), ChangeCategory.DOCUMENTATION); + assertEquals(event.getOperation(), ChangeOperation.MODIFY); + assertTrue(event.getDescription().contains("title changed")); + assertTrue(event.getDescription().contains("Old Title")); + assertTrue(event.getDescription().contains("New Title")); + assertNotNull(event.getParameters()); + assertEquals(event.getParameters().get("oldTitle"), "Old Title"); + assertEquals(event.getParameters().get("newTitle"), "New Title"); + } + + @Test + public void testContentChange() throws Exception { + // Setup + DocumentInfo oldDoc = createDocumentInfo("Title", "Old Content"); + DocumentInfo newDoc = createDocumentInfo("Title", "New Content"); + + EntityAspect previousAspect = createEntityAspect(oldDoc); + EntityAspect currentAspect = createEntityAspect(newDoc); + + // Execute + ChangeTransaction transaction = + generator.getSemanticDiff( + previousAspect, currentAspect, ChangeCategory.DOCUMENTATION, null, false); + + // Verify + assertNotNull(transaction); + assertNotNull(transaction.getChangeEvents()); + assertEquals(transaction.getChangeEvents().size(), 1); + + ChangeEvent event = transaction.getChangeEvents().get(0); + assertEquals(event.getCategory(), ChangeCategory.DOCUMENTATION); + assertTrue(event.getDescription().contains("content was modified")); + } + + @Test + public void testParentDocumentChange() throws Exception { + // Setup + Urn oldParentUrn = UrnUtils.getUrn("urn:li:document:old-parent"); + Urn newParentUrn = UrnUtils.getUrn("urn:li:document:new-parent"); + + DocumentInfo oldDoc = createDocumentInfo("Title", "Content"); + ParentDocument oldParent = new ParentDocument(); + oldParent.setDocument(oldParentUrn); + oldDoc.setParentDocument(oldParent); + + DocumentInfo newDoc = createDocumentInfo("Title", "Content"); + ParentDocument newParent = new ParentDocument(); + newParent.setDocument(newParentUrn); + newDoc.setParentDocument(newParent); + + EntityAspect previousAspect = createEntityAspect(oldDoc); + EntityAspect currentAspect = createEntityAspect(newDoc); + + // Execute + ChangeTransaction transaction = + generator.getSemanticDiff(previousAspect, currentAspect, ChangeCategory.TAG, null, false); + + // Verify + assertNotNull(transaction); + assertNotNull(transaction.getChangeEvents()); + assertEquals(transaction.getChangeEvents().size(), 1); + + ChangeEvent event = transaction.getChangeEvents().get(0); + assertEquals(event.getCategory(), ChangeCategory.TAG); + assertEquals(event.getOperation(), ChangeOperation.MODIFY); + assertTrue(event.getDescription().contains("moved")); + assertTrue(event.getDescription().contains(oldParentUrn.toString())); + assertTrue(event.getDescription().contains(newParentUrn.toString())); + } + + @Test + public void testParentDocumentAdded() throws Exception { + // Setup - document moved from root to having a parent + Urn parentUrn = UrnUtils.getUrn("urn:li:document:parent"); + + DocumentInfo oldDoc = createDocumentInfo("Title", "Content"); + // No parent initially + + DocumentInfo newDoc = createDocumentInfo("Title", "Content"); + ParentDocument parent = new ParentDocument(); + parent.setDocument(parentUrn); + newDoc.setParentDocument(parent); + + EntityAspect previousAspect = createEntityAspect(oldDoc); + EntityAspect currentAspect = createEntityAspect(newDoc); + + // Execute + ChangeTransaction transaction = + generator.getSemanticDiff(previousAspect, currentAspect, ChangeCategory.TAG, null, false); + + // Verify + assertNotNull(transaction); + assertNotNull(transaction.getChangeEvents()); + assertEquals(transaction.getChangeEvents().size(), 1); + + ChangeEvent event = transaction.getChangeEvents().get(0); + assertTrue(event.getDescription().contains("moved to parent")); + assertTrue(event.getDescription().contains(parentUrn.toString())); + } + + @Test + public void testRelatedAssetAdded() throws Exception { + // Setup + Urn assetUrn = UrnUtils.getUrn("urn:li:dataset:test-dataset"); + + DocumentInfo oldDoc = createDocumentInfo("Title", "Content"); + DocumentInfo newDoc = createDocumentInfo("Title", "Content"); + + RelatedAssetArray assets = new RelatedAssetArray(); + RelatedAsset asset = new RelatedAsset(); + asset.setAsset(assetUrn); + assets.add(asset); + newDoc.setRelatedAssets(assets); + + EntityAspect previousAspect = createEntityAspect(oldDoc); + EntityAspect currentAspect = createEntityAspect(newDoc); + + // Execute + ChangeTransaction transaction = + generator.getSemanticDiff(previousAspect, currentAspect, ChangeCategory.TAG, null, false); + + // Verify + assertNotNull(transaction); + assertNotNull(transaction.getChangeEvents()); + assertEquals(transaction.getChangeEvents().size(), 1); + + ChangeEvent event = transaction.getChangeEvents().get(0); + assertEquals(event.getOperation(), ChangeOperation.ADD); + assertTrue(event.getDescription().contains("Related asset")); + assertTrue(event.getDescription().contains("added")); + assertEquals(event.getModifier(), assetUrn.toString()); + } + + @Test + public void testRelatedDocumentRemoved() throws Exception { + // Setup + Urn docUrn = UrnUtils.getUrn("urn:li:document:related-doc"); + + DocumentInfo oldDoc = createDocumentInfo("Title", "Content"); + RelatedDocumentArray oldDocs = new RelatedDocumentArray(); + RelatedDocument relatedDoc = new RelatedDocument(); + relatedDoc.setDocument(docUrn); + oldDocs.add(relatedDoc); + oldDoc.setRelatedDocuments(oldDocs); + + DocumentInfo newDoc = createDocumentInfo("Title", "Content"); + // No related documents + + EntityAspect previousAspect = createEntityAspect(oldDoc); + EntityAspect currentAspect = createEntityAspect(newDoc); + + // Execute + ChangeTransaction transaction = + generator.getSemanticDiff(previousAspect, currentAspect, ChangeCategory.TAG, null, false); + + // Verify + assertNotNull(transaction); + assertNotNull(transaction.getChangeEvents()); + assertEquals(transaction.getChangeEvents().size(), 1); + + ChangeEvent event = transaction.getChangeEvents().get(0); + assertEquals(event.getOperation(), ChangeOperation.REMOVE); + assertTrue(event.getDescription().contains("Related document")); + assertTrue(event.getDescription().contains("removed")); + assertEquals(event.getModifier(), docUrn.toString()); + } + + @Test + public void testStateChange() throws Exception { + // Setup + DocumentInfo oldDoc = createDocumentInfo("Title", "Content"); + DocumentStatus oldStatus = new DocumentStatus(); + oldStatus.setState(DocumentState.UNPUBLISHED); + oldDoc.setStatus(oldStatus); + + DocumentInfo newDoc = createDocumentInfo("Title", "Content"); + DocumentStatus newStatus = new DocumentStatus(); + newStatus.setState(DocumentState.PUBLISHED); + newDoc.setStatus(newStatus); + + EntityAspect previousAspect = createEntityAspect(oldDoc); + EntityAspect currentAspect = createEntityAspect(newDoc); + + // Execute + ChangeTransaction transaction = + generator.getSemanticDiff( + previousAspect, currentAspect, ChangeCategory.LIFECYCLE, null, false); + + // Verify + assertNotNull(transaction); + assertNotNull(transaction.getChangeEvents()); + assertEquals(transaction.getChangeEvents().size(), 1); + + ChangeEvent event = transaction.getChangeEvents().get(0); + assertEquals(event.getCategory(), ChangeCategory.LIFECYCLE); + assertEquals(event.getOperation(), ChangeOperation.MODIFY); + assertTrue(event.getDescription().contains("state changed")); + assertTrue(event.getDescription().contains("UNPUBLISHED")); + assertTrue(event.getDescription().contains("PUBLISHED")); + } + + @Test + public void testMultipleChangesInSameTransaction() throws Exception { + // Setup - change both title and content + DocumentInfo oldDoc = createDocumentInfo("Old Title", "Old Content"); + DocumentInfo newDoc = createDocumentInfo("New Title", "New Content"); + + EntityAspect previousAspect = createEntityAspect(oldDoc); + EntityAspect currentAspect = createEntityAspect(newDoc); + + // Execute + ChangeTransaction transaction = + generator.getSemanticDiff( + previousAspect, currentAspect, ChangeCategory.DOCUMENTATION, null, false); + + // Verify + assertNotNull(transaction); + assertNotNull(transaction.getChangeEvents()); + assertEquals(transaction.getChangeEvents().size(), 2); // Title + Content changes + } + + @Test + public void testNoChanges() throws Exception { + // Setup - identical documents + DocumentInfo doc = createDocumentInfo("Title", "Content"); + + EntityAspect previousAspect = createEntityAspect(doc); + EntityAspect currentAspect = createEntityAspect(doc); + + // Execute + ChangeTransaction transaction = + generator.getSemanticDiff( + previousAspect, currentAspect, ChangeCategory.DOCUMENTATION, null, false); + + // Verify + assertNotNull(transaction); + assertNotNull(transaction.getChangeEvents()); + assertEquals(transaction.getChangeEvents().size(), 0); // No changes + } + + // Helper methods + + private DocumentInfo createDocumentInfo(String title, String content) { + DocumentInfo doc = new DocumentInfo(); + doc.setTitle(title); + DocumentContents docContent = new DocumentContents(); + docContent.setText(content); + doc.setContents(docContent); + return doc; + } + + private EntityAspect createEntityAspect(DocumentInfo documentInfo) throws Exception { + EntityAspect aspect = new EntityAspect(); + aspect.setUrn(TEST_URN); + aspect.setAspect("documentInfo"); + aspect.setVersion(1L); + aspect.setMetadata(RecordUtils.toJsonString(documentInfo)); + aspect.setCreatedOn(new Timestamp(TEST_TIME)); + aspect.setCreatedBy(TEST_USER); + return aspect; + } +} diff --git a/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentContents.pdl b/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentContents.pdl new file mode 100644 index 00000000000000..6a9efcf6916a80 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentContents.pdl @@ -0,0 +1,15 @@ +namespace com.linkedin.knowledge + +/** + * The contents of a document + */ +record DocumentContents { + /** + * The text contents of the document. + * This needs to be added to semantic search! + */ + @Searchable = { + "fieldType": "TEXT" + } + text: string +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentInfo.pdl b/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentInfo.pdl new file mode 100644 index 00000000000000..6798f78eaf2090 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentInfo.pdl @@ -0,0 +1,88 @@ +namespace com.linkedin.knowledge + +import com.linkedin.common.AuditStamp +import com.linkedin.common.CustomProperties + +/** + * Information about a document + */ +@Aspect = { + "name": "documentInfo" +} +record DocumentInfo includes CustomProperties { + + /** + * Optional title for the document. + */ + @Searchable = {} + title: optional string + + /** + * Information about the external source of this document. + * Only populated for third-party documents ingested from external systems. + * If null, the document is first-party (created directly in DataHub). + */ + source: optional DocumentSource + + /** + * Visibility status of the document (published, unpublished.) + */ + status: DocumentStatus + + /** + * Content of the document + */ + contents: DocumentContents + + /** + * The time and actor who created the document + */ + @Searchable = { + "/actor": { + "fieldName": "creator", + "fieldType": "URN" + }, + "/time": { + "fieldName": "createdAt", + "fieldType": "DATETIME" + } + } + created: AuditStamp + + /** + * The time and actor who last modified the document (any field) + */ + @Searchable = { + "/actor": { + "fieldName": "lastModifiedBy", + "fieldType": "URN" + }, + "/time": { + "fieldName": "lastModifiedAt", + "fieldType": "DATETIME" + } + } + lastModified: AuditStamp + + /** + * Assets referenced by or related to this document. + */ + relatedAssets: optional array[RelatedAsset] + + /** + * Documents referenced by or related to this document. + */ + relatedDocuments: optional array[RelatedDocument] + + /** + * Parent article for this asset. + */ + parentDocument: optional ParentDocument + + /** + * If this document is a draft, the document it is a draft of. + * When set, this document should be hidden from normal knowledge base browsing and search. + * Only the published document (draftOf target) should be visible to end users. + */ + draftOf: optional DraftOf +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentSource.pdl b/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentSource.pdl new file mode 100644 index 00000000000000..2d8fd2c552bc6c --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentSource.pdl @@ -0,0 +1,38 @@ +namespace com.linkedin.knowledge + +import com.linkedin.common.Urn + +/** + * Information about the source of a document, especially for externally sourced documents. + * This record is embedded within DocumentInfo to track whether a document is first-party + * (created in DataHub) or third-party (ingested from external sources like Slack, Notion, etc.) + */ +record DocumentSource { + /** + * The type of the source (e.g., "Confluence", "Notion", "Google Docs", "SharePoint", "Slack") + */ + sourceType: enum DocumentSourceType { + /** + * Created via the DataHub UI or API + */ + NATIVE + + /** + * External - The document was ingested from an external source. + */ + EXTERNAL + } + + /** + * URL to the external source where this document originated + */ + @Searchable = {} + externalUrl: optional string + + /** + * Unique identifier in the external system. Searchable in case we need to find ingested docs via filtering. + */ + @Searchable = {} + externalId: optional string +} + diff --git a/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentState.pdl b/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentState.pdl new file mode 100644 index 00000000000000..b44f6877bc694f --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentState.pdl @@ -0,0 +1,17 @@ +namespace com.linkedin.knowledge + +/** + * The state of a document + */ +enum DocumentState { + /** + * Document is published and visible to users + */ + PUBLISHED + + /** + * Document is not published publically. + */ + UNPUBLISHED +} + diff --git a/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentStatus.pdl b/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentStatus.pdl new file mode 100644 index 00000000000000..3e7d7ae0119fc4 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/knowledge/DocumentStatus.pdl @@ -0,0 +1,15 @@ +namespace com.linkedin.knowledge + +import com.linkedin.common.AuditStamp + +/** + * Visibility status information for a document + */ +record DocumentStatus { + /** + * The current visibility state of the document + */ + @Searchable = {} + state: DocumentState +} + diff --git a/metadata-models/src/main/pegasus/com/linkedin/knowledge/DraftOf.pdl b/metadata-models/src/main/pegasus/com/linkedin/knowledge/DraftOf.pdl new file mode 100644 index 00000000000000..f3d8415a7f5f5e --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/knowledge/DraftOf.pdl @@ -0,0 +1,25 @@ +namespace com.linkedin.knowledge + +import com.linkedin.common.Urn + +/** + * Indicates this document is a draft of another document. + * Used to separate draft/versioning relationships from hierarchical parent/child relationships. + */ +record DraftOf { + /** + * The document that this document is a draft of. + * When set, this document is a draft/proposed version of the referenced document. + * Draft documents should have UNPUBLISHED status and not appear in normal knowledge base browsing. + */ + @Relationship = { + "name": "IsDraftOf", + "entityTypes": ["document"] + } + @Searchable = { + "fieldName": "draftOf", + "fieldType": "URN" + } + document: Urn +} + diff --git a/metadata-models/src/main/pegasus/com/linkedin/knowledge/ParentDocument.pdl b/metadata-models/src/main/pegasus/com/linkedin/knowledge/ParentDocument.pdl new file mode 100644 index 00000000000000..1cc5d12da99813 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/knowledge/ParentDocument.pdl @@ -0,0 +1,21 @@ +namespace com.linkedin.knowledge + +import com.linkedin.common.Urn + +/** + * The parent document of the document. + */ +record ParentDocument { + /** + * The hierarchical parent document for this document. + */ + @Relationship = { + "name": "IsChildOf", + "entityTypes": ["document"] + } + @Searchable = { + "fieldName": "parentDocument", + "fieldType": "URN" + } + document: Urn +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/knowledge/RelatedAsset.pdl b/metadata-models/src/main/pegasus/com/linkedin/knowledge/RelatedAsset.pdl new file mode 100644 index 00000000000000..8932c722b24dbe --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/knowledge/RelatedAsset.pdl @@ -0,0 +1,20 @@ +namespace com.linkedin.knowledge + +import com.linkedin.common.Urn + +/** + * A data asset referenced by a document. + */ +record RelatedAsset { + /** + * The asset referenced by or related to the document. + */ + @Relationship = { + "name": "RelatedAsset", + "entityTypes": ["container", "dataset", "dataJob", "dataFlow", "dashboard", "chart", "application", "dataPlatform", "mlModel", "mlModelGroup", "mlPrimaryKey", "mlFeatureTable"] + } + @Searchable = { + "fieldName": "relatedAssets" + } + asset: Urn +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/knowledge/RelatedDocument.pdl b/metadata-models/src/main/pegasus/com/linkedin/knowledge/RelatedDocument.pdl new file mode 100644 index 00000000000000..ce37599151ba10 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/knowledge/RelatedDocument.pdl @@ -0,0 +1,21 @@ +namespace com.linkedin.knowledge + +import com.linkedin.common.Urn + +/** + * An document referenced by or related to another document + * Note that this does NOT include child documents. + */ +record RelatedDocument { + /** + * The document referenced by or related to the document. + */ + @Relationship = { + "name": "RelatedDocument", + "entityTypes": ["document"] + } + @Searchable = { + "fieldName": "relatedDocuments" + } + document: Urn +} \ No newline at end of file diff --git a/metadata-models/src/main/pegasus/com/linkedin/metadata/key/DocumentKey.pdl b/metadata-models/src/main/pegasus/com/linkedin/metadata/key/DocumentKey.pdl new file mode 100644 index 00000000000000..704dafa96a35e9 --- /dev/null +++ b/metadata-models/src/main/pegasus/com/linkedin/metadata/key/DocumentKey.pdl @@ -0,0 +1,16 @@ +namespace com.linkedin.metadata.key + +/** + * Key for a Document + */ +@Aspect = { + "name": "documentKey" +} +record DocumentKey { + /** + * Unique identifier for the document. + */ + @Searchable = {} + id: string +} + diff --git a/metadata-models/src/main/resources/entity-registry.yml b/metadata-models/src/main/resources/entity-registry.yml index a4d0035926b0f4..17053262b6dcb4 100644 --- a/metadata-models/src/main/resources/entity-registry.yml +++ b/metadata-models/src/main/resources/entity-registry.yml @@ -330,6 +330,20 @@ entities: - subTypes - displayProperties - assetSettings + - name: document + category: core + keyAspect: documentKey + aspects: + - documentInfo + - status + - ownership + - domains + - structuredProperties + - subTypes + - dataPlatformInstance + - browsePathsV2 + - globalTags + - glossaryTerms - name: dataHubIngestionSource category: internal keyAspect: dataHubIngestionSourceKey diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java index 9f1d9225ade932..4e8cf185fa9303 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/graphql/GraphQLEngineFactory.java @@ -21,6 +21,7 @@ import com.linkedin.gms.factory.common.SiblingGraphServiceFactory; import com.linkedin.gms.factory.config.ConfigurationProvider; import com.linkedin.gms.factory.entityregistry.EntityRegistryFactory; +import com.linkedin.gms.factory.knowledge.DocumentServiceFactory; import com.linkedin.gms.factory.recommendation.RecommendationServiceFactory; import com.linkedin.metadata.client.UsageStatsJavaClient; import com.linkedin.metadata.config.graphql.GraphQLConcurrencyConfiguration; @@ -37,6 +38,7 @@ import com.linkedin.metadata.service.BusinessAttributeService; import com.linkedin.metadata.service.DataHubFileService; import com.linkedin.metadata.service.DataProductService; +import com.linkedin.metadata.service.DocumentService; import com.linkedin.metadata.service.ERModelRelationshipService; import com.linkedin.metadata.service.FormService; import com.linkedin.metadata.service.LineageService; @@ -78,6 +80,7 @@ GitVersionFactory.class, SiblingGraphServiceFactory.class, AssertionServiceFactory.class, + DocumentServiceFactory.class }) public class GraphQLEngineFactory { @Autowired @@ -211,6 +214,10 @@ public class GraphQLEngineFactory { @Qualifier("assertionService") private AssertionService assertionService; + @Autowired + @Qualifier("documentService") + private DocumentService documentService; + @Autowired @Qualifier("pageTemplateService") private PageTemplateService pageTemplateService; @@ -293,6 +300,7 @@ protected GraphQLEngine graphQLEngine( args.setEntityVersioningService(entityVersioningService); args.setConnectionService(_connectionService); args.setAssertionService(assertionService); + args.setDocumentService(documentService); args.setMetricUtils(metricUtils); args.setS3Util(s3Util); diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/knowledge/DocumentServiceFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/knowledge/DocumentServiceFactory.java new file mode 100644 index 00000000000000..58992f2dce8a7e --- /dev/null +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/knowledge/DocumentServiceFactory.java @@ -0,0 +1,21 @@ +package com.linkedin.gms.factory.knowledge; + +import com.linkedin.entity.client.SystemEntityClient; +import com.linkedin.metadata.service.DocumentService; +import javax.annotation.Nonnull; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; + +@Configuration +public class DocumentServiceFactory { + + @Bean(name = "documentService") + @Scope("singleton") + @Nonnull + protected DocumentService getInstance( + @Qualifier("systemEntityClient") final SystemEntityClient systemEntityClient) { + return new DocumentService(systemEntityClient); + } +} diff --git a/metadata-service/services/src/main/java/com/linkedin/metadata/service/DocumentService.java b/metadata-service/services/src/main/java/com/linkedin/metadata/service/DocumentService.java new file mode 100644 index 00000000000000..95c99614e646b7 --- /dev/null +++ b/metadata-service/services/src/main/java/com/linkedin/metadata/service/DocumentService.java @@ -0,0 +1,864 @@ +package com.linkedin.metadata.service; + +import com.linkedin.common.AuditStamp; +import com.linkedin.common.OwnerArray; +import com.linkedin.common.Ownership; +import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.SetMode; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.SystemEntityClient; +import com.linkedin.events.metadata.ChangeType; +import com.linkedin.knowledge.DocumentContents; +import com.linkedin.knowledge.DocumentInfo; +import com.linkedin.knowledge.ParentDocument; +import com.linkedin.knowledge.RelatedAsset; +import com.linkedin.knowledge.RelatedAssetArray; +import com.linkedin.knowledge.RelatedDocument; +import com.linkedin.knowledge.RelatedDocumentArray; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.key.DocumentKey; +import com.linkedin.metadata.query.filter.Condition; +import com.linkedin.metadata.query.filter.ConjunctiveCriterion; +import com.linkedin.metadata.query.filter.ConjunctiveCriterionArray; +import com.linkedin.metadata.query.filter.Criterion; +import com.linkedin.metadata.query.filter.CriterionArray; +import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.metadata.query.filter.SortCriterion; +import com.linkedin.metadata.query.filter.SortOrder; +import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import io.datahubproject.metadata.context.OperationContext; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for managing Documents. + * + *

This service handles CRUD operations for documents, including: - Creating new documents with + * contents and relationships - Updating document contents and relationships - Moving documents + * within the hierarchy - Searching and listing documents - Deleting documents + * + *

Note that no Authorization is performed within the service. The expectation is that the caller + * has already verified the permissions of the active Actor. + */ +@Slf4j +public class DocumentService { + + private final SystemEntityClient entityClient; + + public DocumentService(@Nonnull SystemEntityClient entityClient) { + this.entityClient = entityClient; + } + + /** + * Creates a new document. + * + * @param opContext the operation context + * @param id optional custom ID (if null, generates a UUID) + * @param subTypes optional list of document sub-types + * @param title optional title + * @param source optional source information for externally ingested documents + * @param state optional initial state (UNPUBLISHED or PUBLISHED). If draftOfUrn is provided, this + * will be forced to UNPUBLISHED. + * @param content the document content text + * @param parentDocumentUrn optional parent document URN + * @param relatedAssetUrns optional list of related asset URNs + * @param relatedDocumentUrns optional list of related document URNs + * @param draftOfUrn optional URN of the published document this is a draft of + * @param actorUrn the URN of the user creating the document + * @return the URN of the created document + * @throws Exception if creation fails + */ + @Nonnull + public Urn createDocument( + @Nonnull OperationContext opContext, + @Nullable String id, + @Nullable List subTypes, + @Nullable String title, + @Nullable com.linkedin.knowledge.DocumentSource source, + @Nullable com.linkedin.knowledge.DocumentState state, + @Nonnull String content, + @Nullable Urn parentDocumentUrn, + @Nullable List relatedAssetUrns, + @Nullable List relatedDocumentUrns, + @Nullable Urn draftOfUrn, + @Nonnull Urn actorUrn) + throws Exception { + + // Generate document URN + final String documentId = id != null ? id : UUID.randomUUID().toString(); + final Urn documentUrn = + Urn.createFromString( + String.format("urn:li:%s:%s", Constants.DOCUMENT_ENTITY_NAME, documentId)); + + // Check if document already exists + if (entityClient.exists(opContext, documentUrn)) { + throw new IllegalArgumentException( + String.format("Document with ID %s already exists", documentId)); + } + + // Validate: if draftOfUrn is provided, state must be UNPUBLISHED (or null, which will default + // to UNPUBLISHED) + if (draftOfUrn != null && state == com.linkedin.knowledge.DocumentState.PUBLISHED) { + throw new IllegalArgumentException( + "Cannot create a draft document with PUBLISHED state. Draft documents must be UNPUBLISHED."); + } + + // Create document key + final DocumentKey documentKey = new DocumentKey(); + documentKey.setId(documentId); + + // Create document info + final DocumentInfo documentInfo = new DocumentInfo(); + if (title != null) { + documentInfo.setTitle(title, SetMode.IGNORE_NULL); + } + + // Set source information if provided (for third-party documents) + if (source != null) { + documentInfo.setSource(source, SetMode.IGNORE_NULL); + } + + // Set contents + final DocumentContents documentContents = new DocumentContents(); + documentContents.setText(content); + documentInfo.setContents(documentContents); + + // Set created audit stamp + final AuditStamp created = new AuditStamp(); + created.setTime(System.currentTimeMillis()); + created.setActor(actorUrn); + documentInfo.setCreated(created); + + // Set lastModified audit stamp (same as created for new documents) + final AuditStamp lastModified = new AuditStamp(); + lastModified.setTime(System.currentTimeMillis()); + lastModified.setActor(actorUrn); + documentInfo.setLastModified(lastModified); + + // Set status (default to UNPUBLISHED if not provided, force UNPUBLISHED if draftOfUrn is set) + final com.linkedin.knowledge.DocumentStatus status = + new com.linkedin.knowledge.DocumentStatus(); + com.linkedin.knowledge.DocumentState finalState = + state != null ? state : com.linkedin.knowledge.DocumentState.UNPUBLISHED; + if (draftOfUrn != null) { + finalState = com.linkedin.knowledge.DocumentState.UNPUBLISHED; + } + status.setState(finalState); + documentInfo.setStatus(status, SetMode.IGNORE_NULL); + + // Set draftOf if provided + if (draftOfUrn != null) { + final com.linkedin.knowledge.DraftOf draftOf = new com.linkedin.knowledge.DraftOf(); + draftOf.setDocument(draftOfUrn); + documentInfo.setDraftOf(draftOf, SetMode.IGNORE_NULL); + } + + // Embed relationships inside DocumentInfo before serializing + if (parentDocumentUrn != null) { + final ParentDocument parent = new ParentDocument(); + parent.setDocument(parentDocumentUrn); + documentInfo.setParentDocument(parent, SetMode.IGNORE_NULL); + } + + if (relatedAssetUrns != null && !relatedAssetUrns.isEmpty()) { + final RelatedAssetArray assetsArray = new RelatedAssetArray(); + relatedAssetUrns.forEach( + assetUrn -> { + final RelatedAsset relatedAsset = new RelatedAsset(); + relatedAsset.setAsset(assetUrn); + assetsArray.add(relatedAsset); + }); + documentInfo.setRelatedAssets(assetsArray, SetMode.IGNORE_NULL); + } + + if (relatedDocumentUrns != null && !relatedDocumentUrns.isEmpty()) { + final RelatedDocumentArray documentsArray = new RelatedDocumentArray(); + relatedDocumentUrns.forEach( + relatedDocumentUrn -> { + final RelatedDocument relatedDocument = new RelatedDocument(); + relatedDocument.setDocument(relatedDocumentUrn); + documentsArray.add(relatedDocument); + }); + documentInfo.setRelatedDocuments(documentsArray, SetMode.IGNORE_NULL); + } + + // Create MCP for document info with all relationships embedded + final MetadataChangeProposal infoMcp = new MetadataChangeProposal(); + infoMcp.setEntityUrn(documentUrn); + infoMcp.setEntityType(Constants.DOCUMENT_ENTITY_NAME); + infoMcp.setAspectName(Constants.DOCUMENT_INFO_ASPECT_NAME); + infoMcp.setChangeType(ChangeType.UPSERT); + infoMcp.setAspect(GenericRecordUtils.serializeAspect(documentInfo)); + + // Prepare list of MCPs to ingest + final List mcps = new java.util.ArrayList<>(); + mcps.add(infoMcp); + + // Create MCP for subTypes if provided + if (subTypes != null && !subTypes.isEmpty()) { + final com.linkedin.common.SubTypes subTypesAspect = new com.linkedin.common.SubTypes(); + subTypesAspect.setTypeNames(new com.linkedin.data.template.StringArray(subTypes)); + + final MetadataChangeProposal subTypesMcp = new MetadataChangeProposal(); + subTypesMcp.setEntityUrn(documentUrn); + subTypesMcp.setEntityType(Constants.DOCUMENT_ENTITY_NAME); + subTypesMcp.setAspectName(Constants.SUB_TYPES_ASPECT_NAME); + subTypesMcp.setChangeType(ChangeType.UPSERT); + subTypesMcp.setAspect(GenericRecordUtils.serializeAspect(subTypesAspect)); + mcps.add(subTypesMcp); + } + + // Ingest the document with all aspects + entityClient.batchIngestProposals(opContext, mcps, false); + + log.info("Created document {} for user {}", documentUrn, actorUrn); + return documentUrn; + } + + /** + * Gets a document info by URN. + * + * @param opContext the operation context + * @param documentUrn the document URN + * @return the document info, or null if not found + * @throws Exception if retrieval fails + */ + @Nullable + public DocumentInfo getDocumentInfo(@Nonnull OperationContext opContext, @Nonnull Urn documentUrn) + throws Exception { + + final EntityResponse response = + entityClient.getV2( + opContext, + Constants.DOCUMENT_ENTITY_NAME, + documentUrn, + Set.of(Constants.DOCUMENT_INFO_ASPECT_NAME)); + + if (response == null + || !response.getAspects().containsKey(Constants.DOCUMENT_INFO_ASPECT_NAME)) { + return null; + } + + return new DocumentInfo( + response.getAspects().get(Constants.DOCUMENT_INFO_ASPECT_NAME).getValue().data()); + } + + /** + * Updates the contents of a document. + * + * @param opContext the operation context + * @param documentUrn the document URN + * @param content the new content text + * @param title optional updated title + * @param subTypes optional updated sub-types + * @throws Exception if update fails + */ + public void updateDocumentContents( + @Nonnull OperationContext opContext, + @Nonnull Urn documentUrn, + @Nonnull String content, + @Nullable String title, + @Nullable List subTypes, + @Nonnull Urn actorUrn) + throws Exception { + + // Get existing info + final DocumentInfo existingInfo = getDocumentInfo(opContext, documentUrn); + if (existingInfo == null) { + throw new IllegalArgumentException( + String.format("Document with URN %s does not exist", documentUrn)); + } + + // Update contents + final DocumentContents documentContents = new DocumentContents(); + documentContents.setText(content); + existingInfo.setContents(documentContents); + + // Update title if provided + if (title != null) { + existingInfo.setTitle(title, SetMode.IGNORE_NULL); + } + + // Update lastModified + final AuditStamp lastModified = new AuditStamp(); + lastModified.setTime(System.currentTimeMillis()); + lastModified.setActor(actorUrn); + existingInfo.setLastModified(lastModified); + + // Prepare list of MCPs to ingest + final List mcps = new java.util.ArrayList<>(); + + // Ingest updated info + final MetadataChangeProposal infoMcp = new MetadataChangeProposal(); + infoMcp.setEntityUrn(documentUrn); + infoMcp.setEntityType(Constants.DOCUMENT_ENTITY_NAME); + infoMcp.setAspectName(Constants.DOCUMENT_INFO_ASPECT_NAME); + infoMcp.setChangeType(ChangeType.UPSERT); + infoMcp.setAspect(GenericRecordUtils.serializeAspect(existingInfo)); + mcps.add(infoMcp); + + // Update subTypes if provided + if (subTypes != null && !subTypes.isEmpty()) { + final com.linkedin.common.SubTypes subTypesAspect = new com.linkedin.common.SubTypes(); + subTypesAspect.setTypeNames(new com.linkedin.data.template.StringArray(subTypes)); + + final MetadataChangeProposal subTypesMcp = new MetadataChangeProposal(); + subTypesMcp.setEntityUrn(documentUrn); + subTypesMcp.setEntityType(Constants.DOCUMENT_ENTITY_NAME); + subTypesMcp.setAspectName(Constants.SUB_TYPES_ASPECT_NAME); + subTypesMcp.setChangeType(ChangeType.UPSERT); + subTypesMcp.setAspect(GenericRecordUtils.serializeAspect(subTypesAspect)); + mcps.add(subTypesMcp); + } + + // Batch ingest all proposals + entityClient.batchIngestProposals(opContext, mcps, false); + + log.info("Updated contents for document {}", documentUrn); + } + + /** + * Updates the related entities for a document. + * + * @param opContext the operation context + * @param documentUrn the document URN + * @param relatedAssetUrns optional list of related asset URNs (null = don't change, empty = + * clear) + * @param relatedDocumentUrns optional list of related document URNs (null = don't change, empty = + * clear) + * @throws Exception if update fails + */ + public void updateDocumentRelatedEntities( + @Nonnull OperationContext opContext, + @Nonnull Urn documentUrn, + @Nullable List relatedAssetUrns, + @Nullable List relatedDocumentUrns, + @Nonnull Urn actorUrn) + throws Exception { + + // Fetch existing info + final DocumentInfo info = getDocumentInfo(opContext, documentUrn); + if (info == null) { + throw new IllegalArgumentException( + String.format("Document with URN %s does not exist", documentUrn)); + } + + // Update related assets if provided + if (relatedAssetUrns != null) { + if (relatedAssetUrns.isEmpty()) { + info.removeRelatedAssets(); + } else { + final RelatedAssetArray assetsArray = new RelatedAssetArray(); + relatedAssetUrns.forEach( + assetUrn -> { + final RelatedAsset relatedAsset = new RelatedAsset(); + relatedAsset.setAsset(assetUrn); + assetsArray.add(relatedAsset); + }); + info.setRelatedAssets(assetsArray, SetMode.IGNORE_NULL); + } + } + + // Update related documents if provided + if (relatedDocumentUrns != null) { + if (relatedDocumentUrns.isEmpty()) { + info.removeRelatedDocuments(); + } else { + final RelatedDocumentArray documentsArray = new RelatedDocumentArray(); + relatedDocumentUrns.forEach( + relatedDocumentUrn -> { + final RelatedDocument relatedDocument = new RelatedDocument(); + relatedDocument.setDocument(relatedDocumentUrn); + documentsArray.add(relatedDocument); + }); + info.setRelatedDocuments(documentsArray, SetMode.IGNORE_NULL); + } + } + + // Update lastModified + final AuditStamp lastModified = new AuditStamp(); + lastModified.setTime(System.currentTimeMillis()); + lastModified.setActor(actorUrn); + info.setLastModified(lastModified); + + // Ingest updated info + final MetadataChangeProposal mcp = new MetadataChangeProposal(); + mcp.setEntityUrn(documentUrn); + mcp.setEntityType(Constants.DOCUMENT_ENTITY_NAME); + mcp.setAspectName(Constants.DOCUMENT_INFO_ASPECT_NAME); + mcp.setChangeType(ChangeType.UPSERT); + mcp.setAspect(GenericRecordUtils.serializeAspect(info)); + + entityClient.ingestProposal(opContext, mcp, false); + + log.info("Updated related entities for document {}", documentUrn); + } + + /** + * Moves a document to a different parent. + * + * @param opContext the operation context + * @param documentUrn the document URN to move + * @param newParentUrn the new parent URN (null = move to root) + * @throws Exception if move fails + */ + public void moveDocument( + @Nonnull OperationContext opContext, + @Nonnull Urn documentUrn, + @Nullable Urn newParentUrn, + @Nonnull Urn actorUrn) + throws Exception { + + // Verify document exists + if (!entityClient.exists(opContext, documentUrn)) { + throw new IllegalArgumentException( + String.format("Document with URN %s does not exist", documentUrn)); + } + + // Verify new parent exists if provided + if (newParentUrn != null) { + if (!entityClient.exists(opContext, newParentUrn)) { + throw new IllegalArgumentException( + String.format("Parent Document with URN %s does not exist", newParentUrn)); + } + + // Prevent moving document to itself + if (documentUrn.equals(newParentUrn)) { + throw new IllegalArgumentException("Cannot move a Document to itself as parent"); + } + + // Check for circular references + if (wouldCreateCircularReference(opContext, documentUrn, newParentUrn)) { + throw new IllegalArgumentException( + "Cannot move document: would create a circular parent reference"); + } + } + + // Fetch existing info + final DocumentInfo info = getDocumentInfo(opContext, documentUrn); + if (info == null) { + throw new IllegalArgumentException( + String.format("Document with URN %s does not exist", documentUrn)); + } + + // Update parent + if (newParentUrn != null) { + final ParentDocument parent = new ParentDocument(); + parent.setDocument(newParentUrn); + info.setParentDocument(parent, SetMode.IGNORE_NULL); + } else { + info.removeParentDocument(); + } + + // Update lastModified + final AuditStamp lastModified = new AuditStamp(); + lastModified.setTime(System.currentTimeMillis()); + lastModified.setActor(actorUrn); + info.setLastModified(lastModified); + + // Ingest updated info + final MetadataChangeProposal mcp = new MetadataChangeProposal(); + mcp.setEntityUrn(documentUrn); + mcp.setEntityType(Constants.DOCUMENT_ENTITY_NAME); + mcp.setAspectName(Constants.DOCUMENT_INFO_ASPECT_NAME); + mcp.setChangeType(ChangeType.UPSERT); + mcp.setAspect(GenericRecordUtils.serializeAspect(info)); + + entityClient.ingestProposal(opContext, mcp, false); + + log.info("Moved document {} to parent {}", documentUrn, newParentUrn); + } + + /** + * Update the status of a document. + * + * @param opContext the operation context + * @param documentUrn the URN of the document to update + * @param newState the new state for the document + * @param actorUrn the URN of the user updating the status + * @throws Exception if update fails + */ + public void updateDocumentStatus( + @Nonnull OperationContext opContext, + @Nonnull Urn documentUrn, + @Nonnull com.linkedin.knowledge.DocumentState newState, + @Nonnull Urn actorUrn) + throws Exception { + + // Verify document exists + if (!entityClient.exists(opContext, documentUrn)) { + throw new IllegalArgumentException( + String.format("Document with URN %s does not exist", documentUrn)); + } + + // Fetch existing info + final DocumentInfo info = getDocumentInfo(opContext, documentUrn); + if (info == null) { + throw new IllegalArgumentException( + String.format("Document with URN %s does not exist", documentUrn)); + } + + // Update status + final com.linkedin.knowledge.DocumentStatus status = + new com.linkedin.knowledge.DocumentStatus(); + status.setState(newState); + info.setStatus(status, SetMode.IGNORE_NULL); + + // Update lastModified + final AuditStamp lastModified = new AuditStamp(); + lastModified.setTime(System.currentTimeMillis()); + lastModified.setActor(actorUrn); + info.setLastModified(lastModified); + + // Ingest updated info + final MetadataChangeProposal mcp = new MetadataChangeProposal(); + mcp.setEntityUrn(documentUrn); + mcp.setEntityType(Constants.DOCUMENT_ENTITY_NAME); + mcp.setAspectName(Constants.DOCUMENT_INFO_ASPECT_NAME); + mcp.setChangeType(ChangeType.UPSERT); + mcp.setAspect(GenericRecordUtils.serializeAspect(info)); + + entityClient.ingestProposal(opContext, mcp, false); + + log.info("Updated status of document {} to {}", documentUrn, newState); + } + + /** + * Deletes a document. + * + * @param opContext the operation context + * @param documentUrn the document URN to delete + * @throws Exception if deletion fails + */ + public void deleteDocument(@Nonnull OperationContext opContext, @Nonnull Urn documentUrn) + throws Exception { + + // Verify document exists + if (!entityClient.exists(opContext, documentUrn)) { + throw new IllegalArgumentException( + String.format("Document with URN %s does not exist", documentUrn)); + } + + entityClient.deleteEntity(opContext, documentUrn); + log.info("Deleted document {}", documentUrn); + + // Asynchronously delete all references + try { + entityClient.deleteEntityReferences(opContext, documentUrn); + } catch (Exception e) { + log.error( + "Failed to clear entity references for Document with URN {}: {}", + documentUrn, + e.getMessage()); + } + } + + /** + * Set ownership for a document. + * + * @param opContext the operation context + * @param documentUrn the document URN + * @param owners list of owner URNs with their ownership types + * @param actorUrn the actor performing the operation + * @throws Exception if setting ownership fails + */ + public void setDocumentOwnership( + @Nonnull OperationContext opContext, + @Nonnull Urn documentUrn, + @Nonnull java.util.List owners, + @Nonnull Urn actorUrn) + throws Exception { + + // Create Ownership aspect + final Ownership ownership = new Ownership(); + final OwnerArray ownerArray = new OwnerArray(); + ownerArray.addAll(owners); + ownership.setOwners(ownerArray); + + // Set last modified + final AuditStamp auditStamp = new AuditStamp(); + auditStamp.setTime(System.currentTimeMillis()); + auditStamp.setActor(actorUrn); + ownership.setLastModified(auditStamp); + + // Create MCP for ownership + final MetadataChangeProposal mcp = new MetadataChangeProposal(); + mcp.setEntityUrn(documentUrn); + mcp.setEntityType(Constants.DOCUMENT_ENTITY_NAME); + mcp.setAspectName(Constants.OWNERSHIP_ASPECT_NAME); + mcp.setChangeType(ChangeType.UPSERT); + mcp.setAspect(GenericRecordUtils.serializeAspect(ownership)); + + entityClient.ingestProposal(opContext, mcp, false); + + log.info("Set ownership for document {} with {} owners", documentUrn, owners.size()); + } + + /** + * Searches for documents with filters. + * + * @param opContext the operation context + * @param query search query + * @param filter optional filter + * @param sortCriterion optional sort criterion + * @param start offset + * @param count number of results + * @return search result + * @throws Exception if search fails + */ + @Nonnull + public SearchResult searchDocuments( + @Nonnull OperationContext opContext, + @Nonnull String query, + @Nullable Filter filter, + @Nullable SortCriterion sortCriterion, + int start, + int count) + throws Exception { + + final SortCriterion sort = + sortCriterion != null + ? sortCriterion + : new SortCriterion().setField("createdAt").setOrder(SortOrder.DESCENDING); + + return entityClient.search( + opContext.withSearchFlags(flags -> flags.setFulltext(true)), + Constants.DOCUMENT_ENTITY_NAME, + query, + filter, + Collections.singletonList(sort), + start, + count); + } + + /** + * Builds a filter for parent document. + * + * @param parentDocumentUrn the parent document URN + * @return the filter + */ + @Nonnull + public static Filter buildParentDocumentFilter(@Nullable Urn parentDocumentUrn) { + if (parentDocumentUrn == null) { + return null; + } + + final Criterion parentCriterion = + new Criterion() + .setField("parentDocument") + .setValue(parentDocumentUrn.toString()) + .setCondition(Condition.EQUAL); + + return new Filter() + .setOr( + new ConjunctiveCriterionArray( + new ConjunctiveCriterion() + .setAnd(new CriterionArray(Collections.singletonList(parentCriterion))))); + } + + /** + * Checks if moving a document to a new parent would create a circular reference. + * + * @param opContext the operation context + * @param documentUrn the document being moved + * @param newParentUrn the proposed new parent + * @return true if a circular reference would be created + */ + private boolean wouldCreateCircularReference( + @Nonnull OperationContext opContext, @Nonnull Urn documentUrn, @Nonnull Urn newParentUrn) { + + Set visitedParents = new HashSet<>(); + return checkCircularReference(opContext, documentUrn, newParentUrn, visitedParents); + } + + /** + * Recursively walks up the parent tree to detect circular references. + * + * @param opContext the operation context + * @param documentUrn the document being moved + * @param currentParent the current parent being checked + * @param visitedParents set of already visited parents to prevent infinite loops + * @return true if a circular reference is detected + */ + private boolean checkCircularReference( + @Nonnull OperationContext opContext, + @Nonnull Urn documentUrn, + @Nullable Urn currentParent, + @Nonnull Set visitedParents) { + + // Base case: no parent, no cycle possible + if (currentParent == null) { + return false; + } + + // Base case: we've already visited this parent (infinite loop protection) + if (visitedParents.contains(currentParent)) { + return false; + } + + // Base case: found the document we're trying to move in the parent chain - cycle detected! + if (currentParent.equals(documentUrn)) { + return true; + } + + // Mark this parent as visited + visitedParents.add(currentParent); + + try { + // Get the parent's document info + DocumentInfo parentInfo = getDocumentInfo(opContext, currentParent); + if (parentInfo != null && parentInfo.hasParentDocument()) { + // Recursively check the parent's parent + Urn grandParent = parentInfo.getParentDocument().getDocument(); + return checkCircularReference(opContext, documentUrn, grandParent, visitedParents); + } + } catch (Exception e) { + // If we can't get parent info, assume no cycle for safety + log.warn("Failed to check parent info for {}: {}", currentParent, e.getMessage()); + } + + // No parent found, no cycle + return false; + } + + /** + * Merge a draft document into its parent (the document it is a draft of). This copies the draft's + * content to the published document and optionally deletes the draft. + * + * @param opContext the operation context + * @param draftUrn the URN of the draft document to merge + * @param deleteDraft whether to delete the draft after merging (default: true) + * @param actorUrn the URN of the user performing the merge + * @throws Exception if merge fails + */ + public void mergeDraftIntoParent( + @Nonnull OperationContext opContext, + @Nonnull Urn draftUrn, + boolean deleteDraft, + @Nonnull Urn actorUrn) + throws Exception { + + // Get draft document info + DocumentInfo draftInfo = getDocumentInfo(opContext, draftUrn); + if (draftInfo == null) { + throw new IllegalArgumentException( + String.format("Draft document %s does not exist", draftUrn)); + } + + // Verify this is a draft + if (!draftInfo.hasDraftOf()) { + throw new IllegalArgumentException( + String.format("Document %s is not a draft (draftOf field not set)", draftUrn)); + } + + // Get the published document URN + Urn publishedUrn = draftInfo.getDraftOf().getDocument(); + + // Get published document info + DocumentInfo publishedInfo = getDocumentInfo(opContext, publishedUrn); + if (publishedInfo == null) { + throw new IllegalArgumentException( + String.format("Published document %s does not exist", publishedUrn)); + } + + // Copy draft content to published document (preserving published document's draftOf=null) + publishedInfo.setContents(draftInfo.getContents()); + if (draftInfo.hasTitle()) { + publishedInfo.setTitle(draftInfo.getTitle()); + } + if (draftInfo.hasRelatedAssets()) { + publishedInfo.setRelatedAssets(draftInfo.getRelatedAssets(), SetMode.IGNORE_NULL); + } + if (draftInfo.hasRelatedDocuments()) { + publishedInfo.setRelatedDocuments(draftInfo.getRelatedDocuments(), SetMode.IGNORE_NULL); + } + if (draftInfo.hasParentDocument()) { + publishedInfo.setParentDocument(draftInfo.getParentDocument(), SetMode.IGNORE_NULL); + } + + // Update lastModified + final AuditStamp now = new AuditStamp(); + now.setTime(System.currentTimeMillis()); + now.setActor(actorUrn); + publishedInfo.setLastModified(now); + + // Ensure draftOf is NOT set on published document + publishedInfo.setDraftOf(null, SetMode.REMOVE_IF_NULL); + + // Ingest updated published document + final MetadataChangeProposal infoProposal = new MetadataChangeProposal(); + infoProposal.setEntityUrn(publishedUrn); + infoProposal.setEntityType(Constants.DOCUMENT_ENTITY_NAME); + infoProposal.setAspectName(Constants.DOCUMENT_INFO_ASPECT_NAME); + infoProposal.setChangeType(ChangeType.UPSERT); + infoProposal.setAspect(GenericRecordUtils.serializeAspect(publishedInfo)); + entityClient.ingestProposal(opContext, infoProposal, false); + + log.info("Merged draft {} into published document {}", draftUrn, publishedUrn); + + // Delete draft if requested + if (deleteDraft) { + deleteDocument(opContext, draftUrn); + log.info("Deleted draft document {} after merge", draftUrn); + } + } + + /** + * Get all draft documents for a published document. + * + * @param opContext the operation context + * @param publishedDocumentUrn the URN of the published document + * @param start starting offset + * @param count number of results to return + * @return SearchResult containing draft documents + * @throws Exception if search fails + */ + @Nonnull + public SearchResult getDraftDocuments( + @Nonnull OperationContext opContext, @Nonnull Urn publishedDocumentUrn, int start, int count) + throws Exception { + + // Build filter for draftOf = publishedDocumentUrn + final Filter filter = buildDraftOfFilter(publishedDocumentUrn); + + // Search for draft documents + return entityClient.search( + opContext.withSearchFlags(flags -> flags.setFulltext(false)), + Constants.DOCUMENT_ENTITY_NAME, + "*", + filter, + null, // sort criterion + start, + count); + } + + /** Build a filter to find documents that are drafts of a specific document. */ + public static Filter buildDraftOfFilter(@Nonnull Urn draftOfUrn) { + final Criterion criterion = new Criterion(); + criterion.setField("draftOf"); + criterion.setValue(draftOfUrn.toString()); + criterion.setCondition(Condition.EQUAL); + + final CriterionArray criterionArray = new CriterionArray(); + criterionArray.add(criterion); + + final ConjunctiveCriterion conjunctiveCriterion = new ConjunctiveCriterion(); + conjunctiveCriterion.setAnd(criterionArray); + + final ConjunctiveCriterionArray conjunctiveCriterionArray = new ConjunctiveCriterionArray(); + conjunctiveCriterionArray.add(conjunctiveCriterion); + + final Filter filter = new Filter(); + filter.setOr(conjunctiveCriterionArray); + + return filter; + } +} diff --git a/metadata-service/services/src/test/java/com/linkedin/metadata/service/DocumentServiceTest.java b/metadata-service/services/src/test/java/com/linkedin/metadata/service/DocumentServiceTest.java new file mode 100644 index 00000000000000..dafb3ea21b7cfd --- /dev/null +++ b/metadata-service/services/src/test/java/com/linkedin/metadata/service/DocumentServiceTest.java @@ -0,0 +1,510 @@ +package com.linkedin.metadata.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.linkedin.common.Owner; +import com.linkedin.common.OwnershipType; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.SystemEntityClient; +import com.linkedin.knowledge.DocumentInfo; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.search.SearchEntity; +import com.linkedin.metadata.search.SearchEntityArray; +import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.search.SearchResultMetadata; +import com.linkedin.metadata.utils.GenericRecordUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import io.datahubproject.metadata.context.OperationContext; +import io.datahubproject.test.metadata.context.TestOperationContexts; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class DocumentServiceTest { + + private static final Urn TEST_USER_URN = UrnUtils.getUrn("urn:li:corpuser:testUser"); + private static final Urn TEST_DOCUMENT_URN = UrnUtils.getUrn("urn:li:document:test-document"); + private static final Urn TEST_PARENT_URN = UrnUtils.getUrn("urn:li:document:parent-document"); + private static final Urn TEST_ASSET_URN = UrnUtils.getUrn("urn:li:dataset:test-dataset"); + private static final OperationContext opContext = + TestOperationContexts.userContextNoSearchAuthorization(TEST_USER_URN); + + @Test + public void testCreateArticleSuccess() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + when(mockClient.exists(any(OperationContext.class), any(Urn.class))).thenReturn(false); + + final DocumentService service = new DocumentService(mockClient); + + // Test creating an document + final Urn documentUrn = + service.createDocument( + opContext, + null, // auto-generate ID + java.util.Collections.singletonList("tutorial"), // subTypes + "How to Use DataHub", + null, // source + null, // no initial state (will default to DRAFT) + "This is the content", + null, // no parent + null, // no related assets + null, // no related documents + null, // no draftOfUrn + TEST_USER_URN); + + // Verify the URN was created + Assert.assertNotNull(documentUrn); + Assert.assertEquals(documentUrn.getEntityType(), Constants.DOCUMENT_ENTITY_NAME); + + // Verify ingest was called once (info aspect only, no relationships) + verify(mockClient, times(1)) + .batchIngestProposals(any(OperationContext.class), any(List.class), eq(false)); + } + + @Test + public void testCreateArticleWithRelationships() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + when(mockClient.exists(any(OperationContext.class), any(Urn.class))).thenReturn(false); + + final DocumentService service = new DocumentService(mockClient); + + // Test creating an document with relationships + final Urn documentUrn = + service.createDocument( + opContext, + "custom-id", + java.util.Collections.singletonList("tutorial"), // subTypes + "Advanced Tutorial", + null, // source + com.linkedin.knowledge.DocumentState.PUBLISHED, // explicit state + "Content with custom ID", + TEST_PARENT_URN, + Arrays.asList(TEST_ASSET_URN), + Arrays.asList(TEST_DOCUMENT_URN), + null, // no draftOfUrn + TEST_USER_URN); + + // Verify the URN was created with custom ID + Assert.assertNotNull(documentUrn); + Assert.assertTrue(documentUrn.toString().contains("custom-id")); + + // Verify ingest was called (should batch both info and relationships) + verify(mockClient, times(1)) + .batchIngestProposals(any(OperationContext.class), any(List.class), eq(false)); + } + + @Test + public void testCreateArticleAlreadyExists() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + when(mockClient.exists(any(OperationContext.class), any(Urn.class))).thenReturn(true); + + final DocumentService service = new DocumentService(mockClient); + + // Test creating an document that already exists + try { + service.createDocument( + opContext, + "existing-id", + java.util.Collections.singletonList("tutorial"), // subTypes + "Title", + null, // source + null, // no initial state + "Content", + null, + null, + null, + null, // no draftOfUrn + TEST_USER_URN); + Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("already exists")); + } + } + + @Test + public void testGetArticleInfoSuccess() throws Exception { + final SystemEntityClient mockClient = createMockEntityClientWithInfo(); + final DocumentService service = new DocumentService(mockClient); + + // Test getting an document info + final DocumentInfo documentInfo = service.getDocumentInfo(opContext, TEST_DOCUMENT_URN); + + // Verify the document was returned + Assert.assertNotNull(documentInfo); + + // Verify getV2 was called + verify(mockClient, times(1)) + .getV2( + any(OperationContext.class), + eq(Constants.DOCUMENT_ENTITY_NAME), + eq(TEST_DOCUMENT_URN), + any(Set.class)); + } + + @Test + public void testGetArticleInfoNotFound() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + when(mockClient.getV2( + any(OperationContext.class), any(String.class), any(Urn.class), any(Set.class))) + .thenReturn(null); + + final DocumentService service = new DocumentService(mockClient); + + // Test getting a non-existent document + final DocumentInfo documentInfo = service.getDocumentInfo(opContext, TEST_DOCUMENT_URN); + + // Verify null was returned + Assert.assertNull(documentInfo); + } + + @Test + public void testUpdateArticleContentsSuccess() throws Exception { + final SystemEntityClient mockClient = createMockEntityClientWithInfo(); + final DocumentService service = new DocumentService(mockClient); + + // Test updating document contents + service.updateDocumentContents( + opContext, TEST_DOCUMENT_URN, "New content", "Updated Title", null, TEST_USER_URN); + + // Verify batch ingest was called + verify(mockClient, times(1)) + .batchIngestProposals(any(OperationContext.class), any(), eq(false)); + } + + @Test + public void testUpdateArticleContentsNotFound() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + when(mockClient.getV2( + any(OperationContext.class), any(String.class), any(Urn.class), any(Set.class))) + .thenReturn(null); + + final DocumentService service = new DocumentService(mockClient); + + // Test updating a non-existent document + try { + service.updateDocumentContents( + opContext, TEST_DOCUMENT_URN, "Content", null, null, TEST_USER_URN); + Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("does not exist")); + } + } + + @Test + public void testUpdateArticleContentsWithSubType() throws Exception { + final SystemEntityClient mockClient = createMockEntityClientWithInfo(); + final DocumentService service = new DocumentService(mockClient); + + // Test updating document contents with subType + service.updateDocumentContents( + opContext, + TEST_DOCUMENT_URN, + "New content", + "Updated Title", + Arrays.asList("FAQ"), + TEST_USER_URN); + + // Verify batch ingest was called with 2 proposals (info + subTypes) + verify(mockClient, times(1)) + .batchIngestProposals(any(OperationContext.class), any(), eq(false)); + } + + @Test + public void testUpdateArticleRelatedEntitiesSuccess() throws Exception { + final SystemEntityClient mockClient = createMockEntityClientWithRelationships(); + final DocumentService service = new DocumentService(mockClient); + + // Test updating related entities + service.updateDocumentRelatedEntities( + opContext, TEST_DOCUMENT_URN, Arrays.asList(TEST_ASSET_URN), null, TEST_USER_URN); + + // Verify ingest was called + verify(mockClient, times(1)).ingestProposal(any(OperationContext.class), any(), eq(false)); + } + + @Test + public void testMoveArticleSuccess() throws Exception { + final SystemEntityClient mockClient = createMockEntityClientWithRelationships(); + when(mockClient.exists(any(OperationContext.class), any(Urn.class))).thenReturn(true); + + final DocumentService service = new DocumentService(mockClient); + + // Test moving document to new parent + service.moveDocument(opContext, TEST_DOCUMENT_URN, TEST_PARENT_URN, TEST_USER_URN); + + // Verify ingest was called + verify(mockClient, times(1)).ingestProposal(any(OperationContext.class), any(), eq(false)); + } + + @Test + public void testMoveArticleToRoot() throws Exception { + final SystemEntityClient mockClient = createMockEntityClientWithRelationships(); + when(mockClient.exists(any(OperationContext.class), any(Urn.class))).thenReturn(true); + + final DocumentService service = new DocumentService(mockClient); + + // Test moving document to root (no parent) + service.moveDocument(opContext, TEST_DOCUMENT_URN, null, TEST_USER_URN); + + // Verify ingest was called + verify(mockClient, times(1)).ingestProposal(any(OperationContext.class), any(), eq(false)); + } + + @Test + public void testMoveArticleToItself() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + when(mockClient.exists(any(OperationContext.class), any(Urn.class))).thenReturn(true); + + final DocumentService service = new DocumentService(mockClient); + + // Test moving document to itself (should fail) + try { + service.moveDocument(opContext, TEST_DOCUMENT_URN, TEST_DOCUMENT_URN, TEST_USER_URN); + Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("Cannot move")); + } + } + + @Test + public void testDeleteArticleSuccess() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + when(mockClient.exists(any(OperationContext.class), any(Urn.class))).thenReturn(true); + + final DocumentService service = new DocumentService(mockClient); + + // Test deleting an document + service.deleteDocument(opContext, TEST_DOCUMENT_URN); + + // Verify deleteEntity was called + verify(mockClient, times(1)).deleteEntity(any(OperationContext.class), eq(TEST_DOCUMENT_URN)); + + // Verify deleteEntityReferences was called + verify(mockClient, times(1)) + .deleteEntityReferences(any(OperationContext.class), eq(TEST_DOCUMENT_URN)); + } + + @Test + public void testDeleteArticleNotFound() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + when(mockClient.exists(any(OperationContext.class), any(Urn.class))).thenReturn(false); + + final DocumentService service = new DocumentService(mockClient); + + // Test deleting a non-existent document + try { + service.deleteDocument(opContext, TEST_DOCUMENT_URN); + Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("does not exist")); + } + } + + @Test + public void testSearchArticlesSuccess() throws Exception { + final SystemEntityClient mockClient = createMockEntityClientWithSearchResults(); + final DocumentService service = new DocumentService(mockClient); + + // Test searching documents + final SearchResult result = service.searchDocuments(opContext, "tutorial", null, null, 0, 10); + + // Verify search was called + Assert.assertNotNull(result); + Assert.assertEquals(result.getNumEntities(), 5); + + // Verify search method was called + verify(mockClient, times(1)) + .search( + any(OperationContext.class), + eq(Constants.DOCUMENT_ENTITY_NAME), + eq("tutorial"), + any(), + any(List.class), + eq(0), + eq(10)); + } + + // Helper methods to create mock EntityClients + + private SystemEntityClient createMockEntityClientWithInfo() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + + final DocumentInfo info = new DocumentInfo(); + info.setTitle("Test Article"); + + final EnvelopedAspect aspect = new EnvelopedAspect(); + aspect.setValue( + new com.linkedin.entity.Aspect(GenericRecordUtils.serializeAspect(info).data())); + + final EnvelopedAspectMap aspectMap = new EnvelopedAspectMap(); + aspectMap.put(Constants.DOCUMENT_INFO_ASPECT_NAME, aspect); + + final EntityResponse response = new EntityResponse(); + response.setUrn(TEST_DOCUMENT_URN); + response.setAspects(aspectMap); + + when(mockClient.getV2( + any(OperationContext.class), + eq(Constants.DOCUMENT_ENTITY_NAME), + any(Urn.class), + any(Set.class))) + .thenReturn(response); + + when(mockClient.exists(any(OperationContext.class), any(Urn.class))).thenReturn(true); + + return mockClient; + } + + private SystemEntityClient createMockEntityClientWithRelationships() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + + // Create a basic DocumentInfo with some sample data + final DocumentInfo info = new DocumentInfo(); + info.setTitle("Test Article"); + final com.linkedin.knowledge.DocumentContents contents = + new com.linkedin.knowledge.DocumentContents(); + contents.setText("Test content"); + info.setContents(contents); + info.setCreated( + new com.linkedin.common.AuditStamp() + .setTime(System.currentTimeMillis()) + .setActor(UrnUtils.getUrn("urn:li:corpuser:test"))); + + final EnvelopedAspect aspect = new EnvelopedAspect(); + aspect.setValue( + new com.linkedin.entity.Aspect(GenericRecordUtils.serializeAspect(info).data())); + + final EnvelopedAspectMap aspectMap = new EnvelopedAspectMap(); + aspectMap.put(Constants.DOCUMENT_INFO_ASPECT_NAME, aspect); + + final EntityResponse response = new EntityResponse(); + response.setUrn(TEST_DOCUMENT_URN); + response.setAspects(aspectMap); + + when(mockClient.getV2( + any(OperationContext.class), + eq(Constants.DOCUMENT_ENTITY_NAME), + any(Urn.class), + any(Set.class))) + .thenReturn(response); + + return mockClient; + } + + private SystemEntityClient createMockEntityClientWithSearchResults() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + + final SearchResult searchResult = new SearchResult(); + searchResult.setFrom(0); + searchResult.setPageSize(10); + searchResult.setNumEntities(5); + + final SearchEntityArray entities = new SearchEntityArray(); + for (int i = 0; i < 5; i++) { + final SearchEntity entity = new SearchEntity(); + entity.setEntity(UrnUtils.getUrn("urn:li:document:document-" + i)); + entities.add(entity); + } + searchResult.setEntities(entities); + searchResult.setMetadata(new SearchResultMetadata()); + + when(mockClient.search( + any(OperationContext.class), + eq(Constants.DOCUMENT_ENTITY_NAME), + any(String.class), + any(), + any(List.class), + any(Integer.class), + any(Integer.class))) + .thenReturn(searchResult); + + return mockClient; + } + + @Test + public void testUpdateArticleStatusSuccess() throws Exception { + final SystemEntityClient mockClient = createMockEntityClientWithInfo(); + final DocumentService service = new DocumentService(mockClient); + + // Test updating document status + service.updateDocumentStatus( + opContext, + TEST_DOCUMENT_URN, + com.linkedin.knowledge.DocumentState.PUBLISHED, + TEST_USER_URN); + + // Verify ingest was called to update the info + verify(mockClient, times(1)) + .ingestProposal(any(OperationContext.class), any(MetadataChangeProposal.class), eq(false)); + } + + @Test + public void testUpdateArticleStatusNotFound() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + when(mockClient.exists(any(OperationContext.class), any(Urn.class))).thenReturn(false); + + final DocumentService service = new DocumentService(mockClient); + + // Test updating status for a non-existent document + try { + service.updateDocumentStatus( + opContext, + TEST_DOCUMENT_URN, + com.linkedin.knowledge.DocumentState.PUBLISHED, + TEST_USER_URN); + Assert.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + Assert.assertTrue(e.getMessage().contains("does not exist")); + } + } + + @Test + public void testSetArticleOwnershipSuccess() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + final DocumentService service = new DocumentService(mockClient); + + // Create a list of owners + final Owner owner1 = new Owner(); + owner1.setOwner(TEST_USER_URN); + owner1.setType(OwnershipType.TECHNICAL_OWNER); + + final Urn owner2Urn = UrnUtils.getUrn("urn:li:corpuser:owner2"); + final Owner owner2 = new Owner(); + owner2.setOwner(owner2Urn); + owner2.setType(OwnershipType.BUSINESS_OWNER); + + final List owners = Arrays.asList(owner1, owner2); + + // Test setting ownership + service.setDocumentOwnership(opContext, TEST_DOCUMENT_URN, owners, TEST_USER_URN); + + // Verify that ingestProposal was called once with ownership aspect + verify(mockClient, times(1)) + .ingestProposal(any(OperationContext.class), any(MetadataChangeProposal.class), eq(false)); + } + + @Test + public void testSetArticleOwnershipEmptyList() throws Exception { + final SystemEntityClient mockClient = mock(SystemEntityClient.class); + final DocumentService service = new DocumentService(mockClient); + + // Test setting ownership with empty list (should still work) + service.setDocumentOwnership( + opContext, TEST_DOCUMENT_URN, java.util.Collections.emptyList(), TEST_USER_URN); + + // Verify that ingestProposal was called once + verify(mockClient, times(1)) + .ingestProposal(any(OperationContext.class), any(MetadataChangeProposal.class), eq(false)); + } +} diff --git a/metadata-service/war/src/main/resources/boot/policies.json b/metadata-service/war/src/main/resources/boot/policies.json index 9d10fc5a8719b0..f562ef4688337b 100644 --- a/metadata-service/war/src/main/resources/boot/policies.json +++ b/metadata-service/war/src/main/resources/boot/policies.json @@ -42,7 +42,8 @@ "MANAGE_SYSTEM_OPERATIONS", "GET_PLATFORM_EVENTS", "MANAGE_HOME_PAGE_TEMPLATES", - "GET_METADATA_CHANGE_LOG_EVENTS" + "GET_METADATA_CHANGE_LOG_EVENTS", + "MANAGE_DOCUMENTS" ], "displayName": "Root User - All Platform Privileges", "description": "Grants all platform privileges to root user.", @@ -201,7 +202,8 @@ "MANAGE_SYSTEM_OPERATIONS", "GET_PLATFORM_EVENTS", "MANAGE_HOME_PAGE_TEMPLATES", - "GET_METADATA_CHANGE_LOG_EVENTS" + "GET_METADATA_CHANGE_LOG_EVENTS", + "MANAGE_DOCUMENTS" ], "displayName": "Admins - Platform Policy", "description": "Admins have all platform privileges.", @@ -294,7 +296,8 @@ "MANAGE_STRUCTURED_PROPERTIES", "VIEW_STRUCTURED_PROPERTIES_PAGE", "MANAGE_DOCUMENTATION_FORMS", - "MANAGE_FEATURES" + "MANAGE_FEATURES", + "MANAGE_DOCUMENTS" ], "displayName": "Editors - Platform Policy", "description": "Editors can manage ingestion and view analytics.", @@ -390,7 +393,8 @@ "glossaryNode", "notebook", "dataProduct", - "dataProcessInstance" + "dataProcessInstance", + "document" ], "condition": "EQUALS" } 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 3e7de795aa924c..59bb1ca3477f25 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 @@ -184,6 +184,9 @@ public class PoliciesConfig { "Manage Structured Properties", "Manage structured properties in your instance."); + public static final Privilege MANAGE_DOCUMENTS_PRIVILEGE = + Privilege.of("MANAGE_DOCUMENTS", "Manage Documents", "Manage documents in DataHub"); + public static final Privilege VIEW_STRUCTURED_PROPERTIES_PAGE_PRIVILEGE = Privilege.of( "VIEW_STRUCTURED_PROPERTIES_PAGE", @@ -257,6 +260,7 @@ public class PoliciesConfig { MANAGE_BUSINESS_ATTRIBUTE_PRIVILEGE, MANAGE_CONNECTIONS_PRIVILEGE, MANAGE_STRUCTURED_PROPERTIES_PRIVILEGE, + MANAGE_DOCUMENTS_PRIVILEGE, VIEW_STRUCTURED_PROPERTIES_PAGE_PRIVILEGE, MANAGE_DOCUMENTATION_FORMS_PRIVILEGE, MANAGE_FEATURES_PRIVILEGE, @@ -267,7 +271,7 @@ public class PoliciesConfig { // Resource Privileges // - static final Privilege VIEW_ENTITY_PAGE_PRIVILEGE = + public static final Privilege VIEW_ENTITY_PAGE_PRIVILEGE = Privilege.of("VIEW_ENTITY_PAGE", "View Entity Page", "The ability to view the entity page."); static final Privilege EXISTS_ENTITY_PRIVILEGE = @@ -834,6 +838,24 @@ public class PoliciesConfig { CREATE_ENTITY_PRIVILEGE, EXISTS_ENTITY_PRIVILEGE)); + // Knowledge Article Privileges + public static final ResourcePrivileges DOCUMENT_PRIVILEGES = + ResourcePrivileges.of( + "document", + "Documents", + "Documents created on DataHub", + ImmutableList.of( + VIEW_ENTITY_PAGE_PRIVILEGE, + EDIT_ENTITY_OWNERS_PRIVILEGE, + EDIT_ENTITY_DOCS_PRIVILEGE, + EDIT_ENTITY_DOC_LINKS_PRIVILEGE, + EDIT_ENTITY_PRIVILEGE, + CREATE_ENTITY_PRIVILEGE, + EXISTS_ENTITY_PRIVILEGE, + EDIT_ENTITY_DOMAINS_PRIVILEGE, + EDIT_ENTITY_PROPERTIES_PRIVILEGE, + MANAGE_DOCUMENTS_PRIVILEGE)); + // Group Privileges public static final ResourcePrivileges CORP_GROUP_PRIVILEGES = ResourcePrivileges.of( @@ -940,6 +962,7 @@ public class PoliciesConfig { DOMAIN_PRIVILEGES, GLOSSARY_TERM_PRIVILEGES, GLOSSARY_NODE_PRIVILEGES, + DOCUMENT_PRIVILEGES, CORP_GROUP_PRIVILEGES, CORP_USER_PRIVILEGES, NOTEBOOK_PRIVILEGES, diff --git a/smoke-test/tests/knowledge/__init__.py b/smoke-test/tests/knowledge/__init__.py new file mode 100644 index 00000000000000..8b137891791fe9 --- /dev/null +++ b/smoke-test/tests/knowledge/__init__.py @@ -0,0 +1 @@ + diff --git a/smoke-test/tests/knowledge/document_change_history_test.py b/smoke-test/tests/knowledge/document_change_history_test.py new file mode 100644 index 00000000000000..dda68407c352b2 --- /dev/null +++ b/smoke-test/tests/knowledge/document_change_history_test.py @@ -0,0 +1,281 @@ +""" +Smoke tests for Document Change History GraphQL API. + +Validates end-to-end functionality of: +- Querying document change history +- Verifying change events are captured +""" + +import time +import uuid + +import pytest + +from tests.consistency_utils import wait_for_writes_to_sync + + +def execute_graphql(auth_session, query: str, variables: dict | None = None) -> dict: + """Execute a GraphQL query against the frontend API.""" + payload = {"query": query, "variables": variables or {}} + response = auth_session.post( + f"{auth_session.frontend_url()}/api/graphql", json=payload + ) + response.raise_for_status() + result = response.json() + return result + + +def _unique_id(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +@pytest.mark.dependency() +def test_document_change_history(auth_session): + """Test document change history tracking.""" + document_id = _unique_id("smoke-doc-history") + + # Create a document + create_mutation = """ + mutation CreateKA($input: CreateDocumentInput!) { + createDocument(input: $input) + } + """ + create_vars = { + "input": { + "id": document_id, + "subType": "faq", + "title": "Change History Test", + "contents": {"text": "Initial content"}, + } + } + create_res = execute_graphql(auth_session, create_mutation, create_vars) + assert "errors" not in create_res, f"GraphQL errors: {create_res.get('errors')}" + urn = create_res["data"]["createDocument"] + assert urn is not None + + wait_for_writes_to_sync() + time.sleep(2) + + # Update the document content + update_mutation = """ + mutation UpdateContents($input: UpdateDocumentContentsInput!) { + updateDocumentContents(input: $input) + } + """ + update_vars = { + "input": { + "urn": urn, + "contents": {"text": "Updated content"}, + }, + } + update_res = execute_graphql(auth_session, update_mutation, update_vars) + assert "errors" not in update_res, f"GraphQL errors: {update_res.get('errors')}" + assert update_res["data"]["updateDocumentContents"] is True + + wait_for_writes_to_sync() + time.sleep(2) + + # Update the document state + status_mutation = """ + mutation UpdateStatus($input: UpdateDocumentStatusInput!) { + updateDocumentStatus(input: $input) + } + """ + status_vars = {"input": {"urn": urn, "state": "PUBLISHED"}} + status_res = execute_graphql(auth_session, status_mutation, status_vars) + assert "errors" not in status_res, f"GraphQL errors: {status_res.get('errors')}" + assert status_res["data"]["updateDocumentStatus"] is True + + wait_for_writes_to_sync() + time.sleep(2) + + # Query change history + history_query = """ + query GetDocumentHistory($urn: String!) { + document(urn: $urn) { + urn + changeHistory(limit: 50) { + changeType + description + actor { + urn + } + timestamp + } + } + } + """ + history_vars = {"urn": urn} + history_res = execute_graphql(auth_session, history_query, history_vars) + assert "errors" not in history_res, f"GraphQL errors: {history_res.get('errors')}" + + doc = history_res["data"]["document"] + assert doc is not None + assert doc["urn"] == urn + + change_history = doc["changeHistory"] + assert change_history is not None + assert isinstance(change_history, list) + + # We should have at least the creation event + # (content and state changes may or may not be captured depending on implementation) + assert len(change_history) >= 1, "Expected at least one change event (creation)" + + # Check that each change has the required fields + for change in change_history: + assert "changeType" in change + assert "description" in change + assert "timestamp" in change + assert isinstance(change["timestamp"], int) + # Actor is optional + if change.get("actor"): + assert change["actor"]["urn"] is not None + + # Check if we have a CREATED event + change_types = [c["changeType"] for c in change_history] + print(f"Change history types: {change_types}") + + # Basic smoke test - just verify we can query change history + # and it has the right structure (actual event generation depends on + # Timeline Service configuration and entity registration) + + # Cleanup + delete_mutation = """ + mutation DeleteKA($urn: String!) { deleteDocument(urn: $urn) } + """ + del_res = execute_graphql(auth_session, delete_mutation, {"urn": urn}) + assert del_res["data"]["deleteDocument"] is True + + +@pytest.mark.dependency(depends=["test_document_change_history"]) +def test_document_change_history_with_time_range(auth_session): + """Test document change history with time range parameters.""" + document_id = _unique_id("smoke-doc-history-time") + + # Create a document + create_mutation = """ + mutation CreateKA($input: CreateDocumentInput!) { + createDocument(input: $input) + } + """ + create_vars = { + "input": { + "id": document_id, + "subType": "guide", + "title": "Time Range Test", + "contents": {"text": "Test content"}, + } + } + create_res = execute_graphql(auth_session, create_mutation, create_vars) + assert "errors" not in create_res, f"GraphQL errors: {create_res.get('errors')}" + urn = create_res["data"]["createDocument"] + assert urn is not None + + wait_for_writes_to_sync() + time.sleep(2) + + # Query change history with time range + current_time = int(time.time() * 1000) + thirty_days_ago = current_time - (30 * 24 * 60 * 60 * 1000) + + history_query = """ + query GetDocumentHistory($urn: String!, $startTime: Long, $endTime: Long, $limit: Int) { + document(urn: $urn) { + urn + changeHistory(startTimeMillis: $startTime, endTimeMillis: $endTime, limit: $limit) { + changeType + description + timestamp + } + } + } + """ + history_vars = { + "urn": urn, + "startTime": thirty_days_ago, + "endTime": current_time, + "limit": 10, + } + history_res = execute_graphql(auth_session, history_query, history_vars) + assert "errors" not in history_res, f"GraphQL errors: {history_res.get('errors')}" + + doc = history_res["data"]["document"] + assert doc is not None + + change_history = doc["changeHistory"] + assert change_history is not None + assert isinstance(change_history, list) + + # Verify the limit is respected + assert len(change_history) <= 10, "Expected at most 10 change events" + + # Cleanup + delete_mutation = """ + mutation DeleteKA($urn: String!) { deleteDocument(urn: $urn) } + """ + del_res = execute_graphql(auth_session, delete_mutation, {"urn": urn}) + assert del_res["data"]["deleteDocument"] is True + + +@pytest.mark.dependency(depends=["test_document_change_history"]) +def test_document_change_history_empty(auth_session): + """Test change history for a document with no changes (or future time range).""" + document_id = _unique_id("smoke-doc-history-empty") + + # Create a document + create_mutation = """ + mutation CreateKA($input: CreateDocumentInput!) { + createDocument(input: $input) + } + """ + create_vars = { + "input": { + "id": document_id, + "subType": "reference", + "title": "Empty History Test", + "contents": {"text": "Test"}, + } + } + create_res = execute_graphql(auth_session, create_mutation, create_vars) + assert "errors" not in create_res, f"GraphQL errors: {create_res.get('errors')}" + urn = create_res["data"]["createDocument"] + assert urn is not None + + wait_for_writes_to_sync() + time.sleep(2) + + # Query change history with a future time range (should return empty) + future_start = int(time.time() * 1000) + ( + 365 * 24 * 60 * 60 * 1000 + ) # 1 year in future + future_end = future_start + (30 * 24 * 60 * 60 * 1000) # 30 days after that + + history_query = """ + query GetDocumentHistory($urn: String!, $startTime: Long, $endTime: Long) { + document(urn: $urn) { + changeHistory(startTimeMillis: $startTime, endTimeMillis: $endTime) { + changeType + description + } + } + } + """ + history_vars = {"urn": urn, "startTime": future_start, "endTime": future_end} + history_res = execute_graphql(auth_session, history_query, history_vars) + assert "errors" not in history_res, f"GraphQL errors: {history_res.get('errors')}" + + doc = history_res["data"]["document"] + assert doc is not None + + change_history = doc["changeHistory"] + assert change_history is not None + assert isinstance(change_history, list) + # Should be empty since we queried a future time range + assert len(change_history) == 0, "Expected no change events in future time range" + + # Cleanup + delete_mutation = """ + mutation DeleteKA($urn: String!) { deleteDocument(urn: $urn) } + """ + del_res = execute_graphql(auth_session, delete_mutation, {"urn": urn}) + assert del_res["data"]["deleteDocument"] is True diff --git a/smoke-test/tests/knowledge/document_draft_test.py b/smoke-test/tests/knowledge/document_draft_test.py new file mode 100644 index 00000000000000..bdc866ce831306 --- /dev/null +++ b/smoke-test/tests/knowledge/document_draft_test.py @@ -0,0 +1,326 @@ +""" +Smoke tests for Document Draft GraphQL APIs. + +Validates end-to-end functionality of: +- Creating drafts using draftFor +- Document.drafts field +- mergeDraft mutation +- Search excludes drafts by default + +Tests are idempotent and use unique IDs for created documents. +""" + +import time +import uuid + +import pytest + +from tests.consistency_utils import wait_for_writes_to_sync + + +def execute_graphql(auth_session, query: str, variables: dict | None = None) -> dict: + """Execute a GraphQL query against the frontend API.""" + payload = {"query": query, "variables": variables or {}} + response = auth_session.post( + f"{auth_session.frontend_url()}/api/graphql", json=payload + ) + response.raise_for_status() + result = response.json() + return result + + +def _unique_id(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +@pytest.mark.dependency() +def test_create_document_draft(auth_session): + """ + Test creating a draft document. + 1. Create a published document. + 2. Create a draft for that document using draftFor. + 3. Verify the draft is linked to the published document. + 4. Verify the published document shows the draft in its drafts field. + 5. Clean up both documents. + """ + published_id = _unique_id("smoke-doc-published") + draft_id = _unique_id("smoke-doc-draft") + + # Create published document + create_mutation = """ + mutation CreateKA($input: CreateDocumentInput!) { + createDocument(input: $input) + } + """ + published_vars = { + "input": { + "id": published_id, + "subType": "guide", + "title": f"Published Doc {published_id}", + "contents": {"text": "Published content"}, + "state": "PUBLISHED", + } + } + published_res = execute_graphql(auth_session, create_mutation, published_vars) + published_urn = published_res["data"]["createDocument"] + + wait_for_writes_to_sync() + + # Create draft document + draft_vars = { + "input": { + "id": draft_id, + "subType": "guide", + "title": f"Draft Doc {draft_id}", + "contents": {"text": "Draft content"}, + "draftFor": published_urn, + } + } + draft_res = execute_graphql(auth_session, create_mutation, draft_vars) + draft_urn = draft_res["data"]["createDocument"] + + wait_for_writes_to_sync() + + # Verify draft is linked to published document + get_query = """ + query GetKA($urn: String!) { + document(urn: $urn) { + urn + info { + title + status { state } + draftOf { + document { urn } + } + } + } + } + """ + draft_get_res = execute_graphql(auth_session, get_query, {"urn": draft_urn}) + draft_doc = draft_get_res["data"]["document"] + assert draft_doc["info"]["status"]["state"] == "UNPUBLISHED" + assert draft_doc["info"]["draftOf"] is not None + assert draft_doc["info"]["draftOf"]["document"]["urn"] == published_urn + + # Verify published document shows the draft + get_drafts_query = """ + query GetKAWithDrafts($urn: String!) { + document(urn: $urn) { + urn + info { + title + status { state } + } + drafts { + urn + } + } + } + """ + published_get_res = execute_graphql( + auth_session, get_drafts_query, {"urn": published_urn} + ) + published_doc = published_get_res["data"]["document"] + assert published_doc["info"]["status"]["state"] == "PUBLISHED" + assert published_doc["drafts"] is not None + assert len(published_doc["drafts"]) >= 1 + draft_urns = [d["urn"] for d in published_doc["drafts"]] + assert draft_urn in draft_urns + + # Cleanup + delete_mutation = """ + mutation DeleteKA($urn: String!) { deleteDocument(urn: $urn) } + """ + execute_graphql(auth_session, delete_mutation, {"urn": draft_urn}) + execute_graphql(auth_session, delete_mutation, {"urn": published_urn}) + + +@pytest.mark.dependency() +def test_merge_draft(auth_session): + """ + Test merging a draft into its published parent. + 1. Create a published document. + 2. Create a draft for that document. + 3. Update the draft's content. + 4. Merge the draft into the published document. + 5. Verify the published document has the draft's content. + 6. Clean up. + """ + published_id = _unique_id("smoke-doc-merge-pub") + draft_id = _unique_id("smoke-doc-merge-draft") + + # Create published document + create_mutation = """ + mutation CreateKA($input: CreateDocumentInput!) { + createDocument(input: $input) + } + """ + published_vars = { + "input": { + "id": published_id, + "subType": "guide", + "title": f"Merge Published {published_id}", + "contents": {"text": "Original published content"}, + "state": "PUBLISHED", + } + } + published_res = execute_graphql(auth_session, create_mutation, published_vars) + published_urn = published_res["data"]["createDocument"] + + wait_for_writes_to_sync() + + # Create draft document + draft_vars = { + "input": { + "id": draft_id, + "subType": "guide", + "title": f"Merge Draft {draft_id}", + "contents": {"text": "Updated draft content"}, + "draftFor": published_urn, + } + } + draft_res = execute_graphql(auth_session, create_mutation, draft_vars) + draft_urn = draft_res["data"]["createDocument"] + + wait_for_writes_to_sync() + + # Merge draft into published document + merge_mutation = """ + mutation MergeDraft($input: MergeDraftInput!) { + mergeDraft(input: $input) + } + """ + merge_vars = {"input": {"draftUrn": draft_urn, "deleteDraft": True}} + merge_res = execute_graphql(auth_session, merge_mutation, merge_vars) + assert merge_res["data"]["mergeDraft"] is True + + wait_for_writes_to_sync() + + # Verify published document has the draft's content + get_query = """ + query GetKA($urn: String!) { + document(urn: $urn) { + urn + info { + title + contents { text } + status { state } + } + } + } + """ + published_get_res = execute_graphql(auth_session, get_query, {"urn": published_urn}) + published_doc = published_get_res["data"]["document"] + assert published_doc["info"]["title"] == f"Merge Draft {draft_id}" + assert published_doc["info"]["contents"]["text"] == "Updated draft content" + assert published_doc["info"]["status"]["state"] == "PUBLISHED" + + # Verify draft was deleted (entity URN may still exist, but info should be None) + draft_get_res = execute_graphql(auth_session, get_query, {"urn": draft_urn}) + # After deletion, the document either doesn't exist or has no info aspect + assert ( + draft_get_res["data"]["document"] is None + or draft_get_res["data"]["document"]["info"] is None + ) + + # Cleanup published document + delete_mutation = """ + mutation DeleteKA($urn: String!) { deleteDocument(urn: $urn) } + """ + execute_graphql(auth_session, delete_mutation, {"urn": published_urn}) + + +@pytest.mark.dependency() +def test_search_excludes_drafts_by_default(auth_session): + """ + Test that search excludes draft documents by default. + 1. Create a published document. + 2. Create a draft for that document. + 3. Search without includeDrafts - should only see published. + 4. Search with includeDrafts=true - should see both. + 5. Clean up. + """ + published_id = _unique_id("smoke-doc-search-pub") + draft_id = _unique_id("smoke-doc-search-draft") + + # Create published document + create_mutation = """ + mutation CreateKA($input: CreateDocumentInput!) { + createDocument(input: $input) + } + """ + published_vars = { + "input": { + "id": published_id, + "subType": "guide", + "title": f"Search Published {published_id}", + "contents": {"text": "Published searchable content"}, + "state": "PUBLISHED", + } + } + published_res = execute_graphql(auth_session, create_mutation, published_vars) + published_urn = published_res["data"]["createDocument"] + + wait_for_writes_to_sync() + + # Create draft document + draft_vars = { + "input": { + "id": draft_id, + "subType": "guide", + "title": f"Search Draft {draft_id}", + "contents": {"text": "Draft searchable content"}, + "draftFor": published_urn, + } + } + draft_res = execute_graphql(auth_session, create_mutation, draft_vars) + draft_urn = draft_res["data"]["createDocument"] + + wait_for_writes_to_sync() + time.sleep(5) + + # Search without includeDrafts - should exclude drafts + search_query = """ + query SearchKA($input: SearchDocumentsInput!) { + searchDocuments(input: $input) { + start + count + total + documents { urn info { title } } + } + } + """ + search_vars_no_drafts = { + "input": {"start": 0, "count": 100, "states": ["PUBLISHED"]} + } + search_res_no_drafts = execute_graphql( + auth_session, search_query, search_vars_no_drafts + ) + result_no_drafts = search_res_no_drafts["data"]["searchDocuments"] + urns_no_drafts = [a["urn"] for a in result_no_drafts["documents"]] + assert published_urn in urns_no_drafts + assert draft_urn not in urns_no_drafts + + # Search with includeDrafts=true - should include drafts + search_vars_with_drafts = { + "input": { + "start": 0, + "count": 100, + "states": ["PUBLISHED", "UNPUBLISHED"], + "includeDrafts": True, + } + } + search_res_with_drafts = execute_graphql( + auth_session, search_query, search_vars_with_drafts + ) + result_with_drafts = search_res_with_drafts["data"]["searchDocuments"] + urns_with_drafts = [a["urn"] for a in result_with_drafts["documents"]] + assert published_urn in urns_with_drafts + assert draft_urn in urns_with_drafts + + # Cleanup + delete_mutation = """ + mutation DeleteKA($urn: String!) { deleteDocument(urn: $urn) } + """ + execute_graphql(auth_session, delete_mutation, {"urn": draft_urn}) + execute_graphql(auth_session, delete_mutation, {"urn": published_urn}) diff --git a/smoke-test/tests/knowledge/document_test.py b/smoke-test/tests/knowledge/document_test.py new file mode 100644 index 00000000000000..49f6eb17557071 --- /dev/null +++ b/smoke-test/tests/knowledge/document_test.py @@ -0,0 +1,410 @@ +""" +Smoke tests for Document GraphQL APIs. + +Validates end-to-end functionality of: +- createDocument (with and without owners) +- document (get) +- updateDocumentContents +- updateDocumentStatus +- searchDocuments + +Tests are idempotent and use unique IDs for created documents. +""" + +import time +import uuid + +import pytest + +from tests.consistency_utils import wait_for_writes_to_sync + + +def execute_graphql(auth_session, query: str, variables: dict | None = None) -> dict: + """Execute a GraphQL query against the frontend API.""" + payload = {"query": query, "variables": variables or {}} + response = auth_session.post( + f"{auth_session.frontend_url()}/api/graphql", json=payload + ) + response.raise_for_status() + result = response.json() + return result + + +def _unique_id(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +@pytest.mark.dependency() +def test_create_document(auth_session): + document_id = _unique_id("smoke-doc-create") + + create_mutation = """ + mutation CreateKA($input: CreateDocumentInput!) { + createDocument(input: $input) + } + """ + + variables = { + "input": { + "id": document_id, + "subType": "how-to", + "title": f"Smoke Create {document_id}", + "contents": {"text": "Initial content"}, + } + } + + result = execute_graphql(auth_session, create_mutation, variables) + assert "errors" not in result, f"GraphQL errors: {result.get('errors')}" + urn = result["data"]["createDocument"] + assert urn.startswith("urn:li:document:"), f"Unexpected URN: {urn}" + + # Cleanup + delete_mutation = """ + mutation DeleteKA($urn: String!) { deleteDocument(urn: $urn) } + """ + del_res = execute_graphql(auth_session, delete_mutation, {"urn": urn}) + assert del_res["data"]["deleteDocument"] is True + + +@pytest.mark.dependency() +def test_get_document(auth_session): + document_id = _unique_id("smoke-doc-get") + + # Create + create_mutation = """ + mutation CreateKA($input: CreateDocumentInput!) { + createDocument(input: $input) + } + """ + variables = { + "input": { + "id": document_id, + "subType": "reference", + "title": f"Smoke Get {document_id}", + "contents": {"text": "Get content"}, + } + } + create_res = execute_graphql(auth_session, create_mutation, variables) + urn = create_res["data"]["createDocument"] + + wait_for_writes_to_sync() + + get_query = """ + query GetKA($urn: String!) { + document(urn: $urn) { + urn + type + subType + info { + title + source { + sourceType + } + status { state } + contents { text } + created { time actor } + lastModified { time actor } + relatedAssets { asset { urn } } + relatedDocuments { document { urn } } + parentDocument { document { urn } } + } + } + } + """ + get_res = execute_graphql(auth_session, get_query, {"urn": urn}) + assert "errors" not in get_res, f"GraphQL errors: {get_res.get('errors')}" + ka = get_res["data"]["document"] + assert ka and ka["urn"] == urn + assert ka["subType"] == "reference" + assert ka["info"]["title"].startswith("Smoke Get ") + # Verify source is automatically set to NATIVE (users can't set this via API) + # Note: This requires the backend to be restarted with the updated code + if ka["info"]["source"] is not None: + assert ka["info"]["source"]["sourceType"] == "NATIVE" + assert ka["info"]["contents"]["text"] == "Get content" + assert ka["info"]["status"]["state"] == "UNPUBLISHED" # Default state + assert ka["info"]["created"]["time"] > 0 + assert ka["info"]["lastModified"]["time"] > 0 + + # Cleanup + delete_mutation = """ + mutation DeleteKA($urn: String!) { deleteDocument(urn: $urn) } + """ + del_res = execute_graphql(auth_session, delete_mutation, {"urn": urn}) + assert del_res["data"]["deleteDocument"] is True + + +@pytest.mark.dependency() +def test_update_document_contents(auth_session): + document_id = _unique_id("smoke-doc-update") + + # Create + create_mutation = """ + mutation CreateKA($input: CreateDocumentInput!) { + createDocument(input: $input) + } + """ + variables = { + "input": { + "id": document_id, + "subType": "guide", + "title": f"Smoke Update {document_id}", + "contents": {"text": "Old content"}, + } + } + create_res = execute_graphql(auth_session, create_mutation, variables) + urn = create_res["data"]["createDocument"] + + wait_for_writes_to_sync() + + # Update contents + update_mutation = """ + mutation UpdateKA($input: UpdateDocumentContentsInput!) { + updateDocumentContents(input: $input) + } + """ + update_vars = { + "input": { + "urn": urn, + "title": f"Smoke Updated {document_id}", + "contents": {"text": "New content"}, + } + } + update_res = execute_graphql(auth_session, update_mutation, update_vars) + assert update_res["data"]["updateDocumentContents"] is True + + wait_for_writes_to_sync() + + # Verify update and that lastModified changed + get_query = """ + query GetKA($urn: String!) { + document(urn: $urn) { + urn + info { + title + contents { text } + created { time } + lastModified { time actor } + } + } + } + """ + get_res = execute_graphql(auth_session, get_query, {"urn": urn}) + info = get_res["data"]["document"]["info"] + assert info["title"].startswith("Smoke Updated ") + assert info["contents"]["text"] == "New content" + # lastModified should be >= created (and typically later after an update) + assert info["lastModified"]["time"] >= info["created"]["time"] + + # Cleanup + delete_mutation = """ + mutation DeleteKA($urn: String!) { deleteDocument(urn: $urn) } + """ + del_res = execute_graphql(auth_session, delete_mutation, {"urn": urn}) + assert del_res["data"]["deleteDocument"] is True + + +@pytest.mark.dependency() +def test_update_document_status(auth_session): + """ + Test updating document status. + 1. Create an document (defaults to UNPUBLISHED). + 2. Update status to PUBLISHED. + 3. Verify the status changed and lastModified was updated. + 4. Clean up. + """ + document_id = _unique_id("smoke-doc-status") + + # Create document + create_mutation = """ + mutation CreateKA($input: CreateDocumentInput!) { + createDocument(input: $input) + } + """ + variables = { + "input": { + "id": document_id, + "subType": "guide", + "title": f"Smoke Status {document_id}", + "contents": {"text": "Status test content"}, + } + } + create_res = execute_graphql(auth_session, create_mutation, variables) + urn = create_res["data"]["createDocument"] + + wait_for_writes_to_sync() + + # Get initial state + get_query = """ + query GetKA($urn: String!) { + document(urn: $urn) { + info { + status { state } + created { time } + lastModified { time } + } + } + } + """ + initial_res = execute_graphql(auth_session, get_query, {"urn": urn}) + initial_info = initial_res["data"]["document"]["info"] + assert initial_info["status"]["state"] == "UNPUBLISHED" + initial_modified_time = initial_info["lastModified"]["time"] + + # Small delay to ensure timestamp difference + time.sleep(1) + + # Update status to PUBLISHED + update_status_mutation = """ + mutation UpdateStatus($input: UpdateDocumentStatusInput!) { + updateDocumentStatus(input: $input) + } + """ + status_vars = {"input": {"urn": urn, "state": "PUBLISHED"}} + status_res = execute_graphql(auth_session, update_status_mutation, status_vars) + assert "errors" not in status_res, f"GraphQL errors: {status_res.get('errors')}" + assert status_res["data"]["updateDocumentStatus"] is True + + wait_for_writes_to_sync() + + # Verify status changed and lastModified was updated + final_res = execute_graphql(auth_session, get_query, {"urn": urn}) + final_info = final_res["data"]["document"]["info"] + assert final_info["status"]["state"] == "PUBLISHED" + # lastModified should have been updated (newer timestamp) + assert final_info["lastModified"]["time"] >= initial_modified_time + + # Cleanup + delete_mutation = """ + mutation DeleteKA($urn: String!) { deleteDocument(urn: $urn) } + """ + del_res = execute_graphql(auth_session, delete_mutation, {"urn": urn}) + assert del_res["data"]["deleteDocument"] is True + + +@pytest.mark.dependency() +def test_create_document_with_owners(auth_session): + """ + Test creating document with custom owners. + 1. Create an document with two owners. + 2. Verify the document was created successfully. + 3. Retrieve the document and verify ownership. + 4. Clean up. + """ + document_id = _unique_id("smoke-doc-owners") + + # Create document with custom owners + create_mutation = """ + mutation CreateKA($input: CreateDocumentInput!) { + createDocument(input: $input) + } + """ + variables = { + "input": { + "id": document_id, + "subType": "guide", + "title": f"Smoke Owners {document_id}", + "contents": {"text": "Ownership test content"}, + "owners": [ + { + "ownerUrn": "urn:li:corpuser:datahub", + "ownerEntityType": "CORP_USER", + "type": "TECHNICAL_OWNER", + } + ], + } + } + create_res = execute_graphql(auth_session, create_mutation, variables) + assert "errors" not in create_res, f"GraphQL errors: {create_res.get('errors')}" + urn = create_res["data"]["createDocument"] + assert urn.startswith("urn:li:document:") + + wait_for_writes_to_sync() + + # Verify ownership was set + get_query = """ + query GetKA($urn: String!) { + document(urn: $urn) { + urn + ownership { + owners { + owner { + ... on CorpUser { + urn + } + } + type + } + } + } + } + """ + get_res = execute_graphql(auth_session, get_query, {"urn": urn}) + assert "errors" not in get_res, f"GraphQL errors: {get_res.get('errors')}" + ka = get_res["data"]["document"] + assert ka["ownership"] is not None + assert len(ka["ownership"]["owners"]) >= 1 + owner_urns = [o["owner"]["urn"] for o in ka["ownership"]["owners"]] + assert "urn:li:corpuser:datahub" in owner_urns + + # Cleanup + delete_mutation = """ + mutation DeleteKA($urn: String!) { deleteDocument(urn: $urn) } + """ + del_res = execute_graphql(auth_session, delete_mutation, {"urn": urn}) + assert del_res["data"]["deleteDocument"] is True + + +@pytest.mark.dependency() +def test_search_documents(auth_session): + document_id = _unique_id("smoke-doc-search") + title = f"Smoke Search {document_id}" + + # Create + create_mutation = """ + mutation CreateKA($input: CreateDocumentInput!) { + createDocument(input: $input) + } + """ + variables = { + "input": { + "id": document_id, + "subType": "tutorial", + "title": title, + "contents": {"text": "Searchable content"}, + } + } + create_res = execute_graphql(auth_session, create_mutation, variables) + urn = create_res["data"]["createDocument"] + + wait_for_writes_to_sync() + time.sleep(5) + + search_query = """ + query SearchKA($input: SearchDocumentsInput!) { + searchDocuments(input: $input) { + start + count + total + documents { urn info { title } } + } + } + """ + # Include UNPUBLISHED state in search since created documents default to UNPUBLISHED + # (searchDocuments defaults to PUBLISHED only if states not specified) + search_vars = {"input": {"start": 0, "count": 100, "states": ["UNPUBLISHED"]}} + search_res = execute_graphql(auth_session, search_query, search_vars) + assert "errors" not in search_res, f"GraphQL errors: {search_res.get('errors')}" + result = search_res["data"]["searchDocuments"] + assert result["total"] >= 1, f"Expected at least 1 document, got {result['total']}" + urns = [a["urn"] for a in result["documents"]] + assert urn in urns, ( + f"Expected created document {urn} in search results. Found {len(urns)} documents." + ) + + # Cleanup + delete_mutation = """ + mutation DeleteKA($urn: String!) { deleteDocument(urn: $urn) } + """ + del_res = execute_graphql(auth_session, delete_mutation, {"urn": urn}) + assert del_res["data"]["deleteDocument"] is True