diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/DashboardResource.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/DashboardResource.scala index 2c3ce70b5e3..1163327a835 100644 --- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/DashboardResource.scala +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/DashboardResource.scala @@ -1,48 +1,138 @@ package edu.uci.ics.texera.web.resource.dashboard -import edu.uci.ics.texera.web.SqlServer import edu.uci.ics.texera.web.auth.SessionUser -import edu.uci.ics.texera.web.model.jooq.generated.Tables._ -import edu.uci.ics.texera.web.model.jooq.generated.enums.{ - DatasetUserAccessPrivilege, - UserFileAccessPrivilege, - WorkflowUserAccessPrivilege -} import edu.uci.ics.texera.web.model.jooq.generated.tables.pojos._ import edu.uci.ics.texera.web.resource.dashboard.DashboardResource._ -import edu.uci.ics.texera.web.resource.dashboard.user.dataset.DatasetResource -import edu.uci.ics.texera.web.resource.dashboard.user.dataset.DatasetResource.DashboardDataset +import edu.uci.ics.texera.web.resource.dashboard.SearchQueryBuilder.ALL_RESOURCE_TYPE import edu.uci.ics.texera.web.resource.dashboard.user.file.UserFileResource.DashboardFile -import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowResource._ +import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowResource.DashboardWorkflow import io.dropwizard.auth.Auth +import org.jooq.{Field, OrderField} import javax.ws.rs._ import javax.ws.rs.core.MediaType -import org.jooq.Condition -import org.jooq.impl.DSL -import org.jooq.impl.DSL.{falseCondition, groupConcatDistinct, noCondition} import org.jooq.types.UInteger -import java.sql.Timestamp +import java.util import scala.jdk.CollectionConverters.CollectionHasAsScala -/** - * This file handles various requests that need to interact with multiple tables. - */ object DashboardResource { - final private lazy val context = SqlServer.createDSLContext() case class DashboardClickableFileEntry( resourceType: String, - workflow: DashboardWorkflow, - project: Project, - file: DashboardFile, - dataset: DashboardDataset + workflow: Option[DashboardWorkflow] = None, + project: Option[Project] = None, + file: Option[DashboardFile] = None ) - case class DashboardSearchResult( - results: List[DashboardClickableFileEntry], - more: Boolean + case class DashboardSearchResult(results: List[DashboardClickableFileEntry], more: Boolean) + + /* + The following class describe the available params from the frontend for full text search. + * @param user The authenticated user performing the search. + * @param keywords A list of search keywords. The API will return resources that match any of these keywords. + * @param resourceType The type of the resources to include in the search results. Acceptable values are "workflow", "project", "file" and "" (for all types). + * @param creationStartDate The start of the date range for the creation time filter. It should be provided in 'yyyy-MM-dd' format. + * @param creationEndDate The end of the date range for the creation time filter. It should be provided in 'yyyy-MM-dd' format. + * @param modifiedStartDate The start of the date range for the modification time filter. It should be provided in 'yyyy-MM-dd' format. + * @param modifiedEndDate The end of the date range for the modification time filter. It should be provided in 'yyyy-MM-dd' format. + * @param owners A list of owner names to include in the search results. + * @param workflowIDs A list of workflow IDs to include in the search results. + * @param operators A list of operators to include in the search results. + * @param projectIds A list of project IDs to include in the search results. + * @param offset The number of initial results to skip. This is useful for implementing pagination. + * @param count The maximum number of results to return. + * @param orderBy The order in which to sort the results. Acceptable values are 'NameAsc', 'NameDesc', 'CreateTimeDesc', and 'EditTimeDesc'. + */ + case class SearchQueryParams( + @QueryParam("query") keywords: java.util.List[String] = new util.ArrayList[String](), + @QueryParam("resourceType") @DefaultValue("") resourceType: String = ALL_RESOURCE_TYPE, + @QueryParam("createDateStart") @DefaultValue("") creationStartDate: String = "", + @QueryParam("createDateEnd") @DefaultValue("") creationEndDate: String = "", + @QueryParam("modifiedDateStart") @DefaultValue("") modifiedStartDate: String = "", + @QueryParam("modifiedDateEnd") @DefaultValue("") modifiedEndDate: String = "", + @QueryParam("owner") owners: java.util.List[String] = new util.ArrayList(), + @QueryParam("id") workflowIDs: java.util.List[UInteger] = new util.ArrayList(), + @QueryParam("operator") operators: java.util.List[String] = new util.ArrayList(), + @QueryParam("projectId") projectIds: java.util.List[UInteger] = new util.ArrayList(), + @QueryParam("start") @DefaultValue("0") offset: Int = 0, + @QueryParam("count") @DefaultValue("20") count: Int = 20, + @QueryParam("orderBy") @DefaultValue("EditTimeDesc") orderBy: String = "EditTimeDesc" ) + + // Construct query for workflows + + def searchAllResources( + @Auth user: SessionUser, + @BeanParam params: SearchQueryParams + ): DashboardSearchResult = { + val uid = user.getUid + val query = params.resourceType match { + case SearchQueryBuilder.WORKFLOW_RESOURCE_TYPE => + WorkflowSearchQueryBuilder.constructQuery(uid, params) + case SearchQueryBuilder.FILE_RESOURCE_TYPE => + FileSearchQueryBuilder.constructQuery(uid, params) + case SearchQueryBuilder.PROJECT_RESOURCE_TYPE => + ProjectSearchQueryBuilder.constructQuery(uid, params) + case SearchQueryBuilder.ALL_RESOURCE_TYPE => + val q1 = WorkflowSearchQueryBuilder.constructQuery(uid, params) + val q2 = FileSearchQueryBuilder.constructQuery(uid, params) + val q3 = ProjectSearchQueryBuilder.constructQuery(uid, params) + q1.unionAll(q2).unionAll(q3) + case _ => throw new IllegalArgumentException(s"Unknown resource type: ${params.resourceType}") + } + + val finalQuery = + query.orderBy(getOrderFields(params): _*).offset(params.offset).limit(params.count + 1) + val queryResult = finalQuery.fetch() + + val entries = queryResult.asScala.toList + .take(params.count) + .map(record => { + val resourceType = record.get("resourceType", classOf[String]) + resourceType match { + case SearchQueryBuilder.WORKFLOW_RESOURCE_TYPE => + WorkflowSearchQueryBuilder.toEntry(uid, record) + case SearchQueryBuilder.FILE_RESOURCE_TYPE => + FileSearchQueryBuilder.toEntry(uid, record) + case SearchQueryBuilder.PROJECT_RESOURCE_TYPE => + ProjectSearchQueryBuilder.toEntry(uid, record) + } + }) + + DashboardSearchResult(results = entries, more = queryResult.size() > params.count) + } + + def getOrderFields( + searchQueryParams: SearchQueryParams + ): List[OrderField[_]] = { + // Regex pattern to extract column name and order direction + val pattern = "(Name|CreateTime|EditTime)(Asc|Desc)".r + + searchQueryParams.orderBy match { + case pattern(column, order) => + val field = getColumnField(column) + field match { + case Some(value) => + List(order match { + case "Asc" => value.asc() + case "Desc" => value.desc() + }) + case None => List() + } + case _ => List() // Default case if the orderBy string doesn't match the pattern + } + } + + // Helper method to map column names to actual database fields based on resource type + private def getColumnField(columnName: String): Option[Field[_]] = { + Option(columnName match { + case "Name" => UnifiedResourceSchema.resourceNameField + case "CreateTime" => UnifiedResourceSchema.resourceCreationTimeField + case "EditTime" => UnifiedResourceSchema.resourceLastModifiedTimeField + case _ => null // Default case for unmatched resource types or column names + }) + } + } @Produces(Array(MediaType.APPLICATION_JSON)) @@ -58,672 +148,14 @@ class DashboardResource { * This method utilizes MySQL Boolean Full-Text Searches * reference: https://dev.mysql.com/doc/refman/8.0/en/fulltext-boolean.html * - * @param user The authenticated user performing the search. - * @param keywords A list of search keywords. The API will return resources that match any of these keywords. - * @param resourceType The type of the resources to include in the search results. Acceptable values are "workflow", "project", "file" and "" (for all types). - * @param creationStartDate The start of the date range for the creation time filter. It should be provided in 'yyyy-MM-dd' format. - * @param creationEndDate The end of the date range for the creation time filter. It should be provided in 'yyyy-MM-dd' format. - * @param modifiedStartDate The start of the date range for the modification time filter. It should be provided in 'yyyy-MM-dd' format. - * @param modifiedEndDate The end of the date range for the modification time filter. It should be provided in 'yyyy-MM-dd' format. - * @param owners A list of owner names to include in the search results. - * @param workflowIDs A list of workflow IDs to include in the search results. - * @param operators A list of operators to include in the search results. - * @param projectIds A list of project IDs to include in the search results. - * @param offset The number of initial results to skip. This is useful for implementing pagination. - * @param count The maximum number of results to return. - * @param orderBy The order in which to sort the results. Acceptable values are 'NameAsc', 'NameDesc', 'CreateTimeDesc', and 'EditTimeDesc'. * @return A DashboardSearchResult object containing a list of DashboardClickableFileEntry objects that match the search criteria, and a boolean indicating whether more results are available. */ - @GET @Path("/search") - def searchAllResources( + def searchAllResourcesCall( @Auth user: SessionUser, - @QueryParam("query") keywords: java.util.List[String] = new java.util.ArrayList[String](), - @QueryParam("resourceType") @DefaultValue("") resourceType: String = "", - @QueryParam("createDateStart") @DefaultValue("") creationStartDate: String = "", - @QueryParam("createDateEnd") @DefaultValue("") creationEndDate: String = "", - @QueryParam("modifiedDateStart") @DefaultValue("") modifiedStartDate: String = "", - @QueryParam("modifiedDateEnd") @DefaultValue("") modifiedEndDate: String = "", - @QueryParam("owner") owners: java.util.List[String] = new java.util.ArrayList[String](), - @QueryParam("id") workflowIDs: java.util.List[UInteger] = new java.util.ArrayList[UInteger](), - @QueryParam("operator") operators: java.util.List[String] = new java.util.ArrayList[String](), - @QueryParam("projectId") projectIds: java.util.List[UInteger] = - new java.util.ArrayList[UInteger](), - @QueryParam("start") @DefaultValue("0") offset: Int = 0, - @QueryParam("count") @DefaultValue("20") count: Int = 20, - @QueryParam("orderBy") @DefaultValue("EditTimeDesc") orderBy: String = "EditTimeDesc" + @BeanParam params: SearchQueryParams ): DashboardSearchResult = { - // make sure keywords don't contain "+-()<>~*\"", these are reserved for SQL full-text boolean operator - val splitKeywords = keywords.asScala.flatMap(word => word.split("[+\\-()<>~*@\"]+")) - var workflowMatchQuery: Condition = noCondition() - var projectMatchQuery: Condition = noCondition() - var fileMatchQuery: Condition = noCondition() - var datasetMatchQuery: Condition = noCondition() - for (key: String <- splitKeywords) { - if (key != "") { - val words = key.split("\\s+") - - def getSearchQuery(subStringSearchEnabled: Boolean, matchColumnStr: String): String = { - "MATCH(" + matchColumnStr + ") AGAINST(+{0}" + - (if (subStringSearchEnabled) "'*'" else "") + " IN BOOLEAN mode)" - } - - val subStringSearchEnabled = words.length == 1 - workflowMatchQuery = workflowMatchQuery.and( - getSearchQuery( - subStringSearchEnabled, - "texera_db.workflow.name, texera_db.workflow.description, texera_db.workflow.content" - ), - key - ) - projectMatchQuery = projectMatchQuery.and( - getSearchQuery( - subStringSearchEnabled, - "texera_db.project.name, texera_db.project.description" - ), - key - ) - fileMatchQuery = fileMatchQuery.and( - getSearchQuery(subStringSearchEnabled, "texera_db.file.name, texera_db.file.description"), - key - ) - datasetMatchQuery = datasetMatchQuery.and( - getSearchQuery( - subStringSearchEnabled, - "texera_db.dataset.name, texera_db.dataset.description" - ), - key - ) - } - } - - // combine all filters with AND - val workflowOptionalFilters: Condition = createWorkflowFilterCondition( - creationStartDate, - creationEndDate, - modifiedStartDate, - modifiedEndDate, - workflowIDs, - owners, - operators, - projectIds - ) - - var projectOptionalFilters: Condition = noCondition() - projectOptionalFilters = projectOptionalFilters - .and(getDateFilter(creationStartDate, creationEndDate, PROJECT.CREATION_TIME)) - .and(getProjectFilter(projectIds, PROJECT.PID)) - // apply owner filter - .and(getOwnerFilter(owners)) - .and( - // these filters are not available in project. If any of them exists, the query should return 0 project - if ( - modifiedStartDate.nonEmpty || modifiedEndDate.nonEmpty || !workflowIDs.isEmpty || !operators.isEmpty - ) falseCondition() - else noCondition() - ) - - var fileOptionalFilters: Condition = noCondition() - fileOptionalFilters = fileOptionalFilters - .and(getDateFilter(creationStartDate, creationEndDate, FILE.UPLOAD_TIME)) - .and(getOwnerFilter(owners)) - .and( - // these filters are not available in file. If any of them exists, the query should return 0 file - if ( - modifiedStartDate.nonEmpty || modifiedEndDate.nonEmpty || !workflowIDs.isEmpty || !operators.isEmpty || !projectIds.isEmpty - ) falseCondition() - else noCondition() - ) - - /** - * Refer to texera/core/scripts/sql/texera_ddl.sql to understand what each attribute is - * - * Common Attributes (4 columns): All types of resources have these 4 attributes - * 1. `resourceType`: Represents the type of resource (`String`). Allowed value: project, workflow, file - * 2. `name`: Specifies the name of the resource (`String`). - * 3. `description`: Provides a description of the resource (`String`). - * 4. `creation_time`: Indicates the timestamp of when the resource was created (`Timestamp`). It represents upload_time if the resourceType is `file` - * - * Workflow Attributes (6 columns): Only workflow will have these 6 attributes. - * 5. `WID`: Represents the Workflow ID (`UInteger`). - * 6. `lastModifiedTime`: Indicates the timestamp of the last modification made to the workflow (`Timestamp`). - * 7. `privilege`: Specifies the privilege associated with the workflow (`Privilege`). - * 8. `UID`: Represents the User ID associated with the workflow (`UInteger`). - * 9. `userName`: Provides the name of the user associated with the workflow (`String`). - * 10. `projects`: The project IDs for the workflow, concatenated as a string (`String`). - * - * Project Attributes (3 columns): Only project will have these 3 attributes. - * 11. `pid`: Represents the Project ID (`UInteger`). - * 12. `ownerId`: Indicates the ID of the project owner (`UInteger`). - * 13. `color`: Specifies the color associated with the project (`String`). - * - * File Attributes (7 columns): Only files will have these 7 attributes. - * 14. `ownerUID`: Represents the User ID of the file owner (`UInteger`). - * 15. `fid`: Indicates the File ID (`UInteger`). - * 16. `uploadTime`: Indicates the timestamp when the file was uploaded (`Timestamp`). - * 17. `path`: Specifies the path of the file (`String`). - * 18. `size`: Represents the size of the file (`UInteger`). - * 19. `email`: Represents the email associated with the file owner (`String`). - * 20. `userFileAccess`: Specifies the user file access privilege (`UserFileAccessPrivilege`). - */ - - // Retrieve workflow resource - val workflowQuery = - context - .select( - //common attributes: 4 columns - DSL.inline("workflow").as("resourceType"), - WORKFLOW.NAME, - WORKFLOW.DESCRIPTION, - WORKFLOW.CREATION_TIME, - // workflow attributes: 6 columns - WORKFLOW.WID, - WORKFLOW.LAST_MODIFIED_TIME, - WORKFLOW_USER_ACCESS.PRIVILEGE, - WORKFLOW_OF_USER.UID, - USER.NAME, - groupConcatDistinct(WORKFLOW_OF_PROJECT.PID).as("projects"), - // project attributes: 3 columns - DSL.inline(null, classOf[UInteger]).as("pid"), - DSL.inline(null, classOf[UInteger]).as("owner_id"), - DSL.inline(null, classOf[String]).as("color"), - // file attributes 7 columns - DSL.inline(null, classOf[UInteger]).as("owner_uid"), - DSL.inline(null, classOf[UInteger]).as("fid"), - DSL.inline(null, classOf[Timestamp]).as("upload_time"), - DSL.inline(null, classOf[String]).as("path"), - DSL.inline(null, classOf[UInteger]).as("size"), - DSL.inline(null, classOf[String]).as("email"), - DSL.inline(null, classOf[UserFileAccessPrivilege]).as("user_file_access") - ) - .from(WORKFLOW) - .leftJoin(WORKFLOW_USER_ACCESS) - .on(WORKFLOW_USER_ACCESS.WID.eq(WORKFLOW.WID)) - .leftJoin(WORKFLOW_OF_USER) - .on(WORKFLOW_OF_USER.WID.eq(WORKFLOW.WID)) - .leftJoin(USER) - .on(USER.UID.eq(WORKFLOW_OF_USER.UID)) - .leftJoin(WORKFLOW_OF_PROJECT) - .on(WORKFLOW_OF_PROJECT.WID.eq(WORKFLOW.WID)) - .leftJoin(PROJECT_USER_ACCESS) - .on(PROJECT_USER_ACCESS.PID.eq(WORKFLOW_OF_PROJECT.PID)) - .where( - WORKFLOW_USER_ACCESS.UID.eq(user.getUid).or(PROJECT_USER_ACCESS.UID.eq(user.getUid)) - ) - .and(workflowMatchQuery) - .and(workflowOptionalFilters) - .groupBy(WORKFLOW.WID) - - // Retrieve project resource - val projectQuery = context - .select( - //common attributes: 4 columns - DSL.inline("project").as("resourceType"), - PROJECT.NAME.as("name"), - PROJECT.DESCRIPTION.as("description"), - PROJECT.CREATION_TIME.as("creation_time"), - // workflow attributes: 6 columns - DSL.inline(null, classOf[UInteger]).as("wid"), - DSL.inline(PROJECT.CREATION_TIME, classOf[Timestamp]).as("last_modified_time"), - DSL.inline(null, classOf[WorkflowUserAccessPrivilege]).as("privilege"), - DSL.inline(null, classOf[UInteger]).as("uid"), - DSL.inline(null, classOf[String]).as("userName"), - DSL.inline(null, classOf[String]).as("projects"), - // project attributes: 3 columns - PROJECT.PID, - PROJECT.OWNER_ID, - PROJECT.COLOR, - // file attributes 7 columns - DSL.inline(null, classOf[UInteger]).as("owner_uid"), - DSL.inline(null, classOf[UInteger]).as("fid"), - DSL.inline(null, classOf[Timestamp]).as("upload_time"), - DSL.inline(null, classOf[String]).as("path"), - DSL.inline(null, classOf[UInteger]).as("size"), - DSL.inline(null, classOf[String]).as("email"), - DSL.inline(null, classOf[UserFileAccessPrivilege]).as("user_file_access") - ) - .from(PROJECT) - .leftJoin(PROJECT_USER_ACCESS) - .on(PROJECT_USER_ACCESS.PID.eq(PROJECT.PID)) - .where(PROJECT_USER_ACCESS.UID.eq(user.getUid)) - .and( - projectMatchQuery - ) - .and(projectOptionalFilters) - - val datasetQuery = context - .select( - DSL.inline("dataset").as("resourceType"), - DATASET.NAME, - DATASET.DESCRIPTION, - DATASET.DID, - DATASET.OWNER_UID, - DATASET.IS_PUBLIC, - DATASET.CREATION_TIME, - USER.NAME.as("userName"), - // use aggregation and groupby to remove duplicated item - DSL.max(DATASET_USER_ACCESS.PRIVILEGE).as("privilege"), - DSL.max(DATASET_USER_ACCESS.UID).as("uid") - ) - .from(DATASET) - .leftJoin(DATASET_USER_ACCESS) - .on(DATASET_USER_ACCESS.DID.eq(DATASET.DID)) - .leftJoin(USER) - .on(USER.UID.eq(DATASET_USER_ACCESS.UID)) - .where( - USER.UID - .eq(user.getUid) - .or(DATASET.IS_PUBLIC.eq(DatasetResource.DATASET_IS_PUBLIC)) - ) - .and(datasetMatchQuery) - .groupBy(DATASET.DID) - - // Retrieve file resource - val fileQuery = context - .select( - // common attributes: 4 columns - DSL.inline("file").as("resourceType"), - FILE.NAME, - FILE.DESCRIPTION, - DSL.inline(FILE.UPLOAD_TIME, classOf[Timestamp]).as("creation_time"), - // workflow attributes: 6 columns - DSL.inline(null, classOf[UInteger]).as("wid"), - DSL.inline(FILE.UPLOAD_TIME, classOf[Timestamp]).as("last_modified_time"), - DSL.inline(null, classOf[WorkflowUserAccessPrivilege]).as("privilege"), - DSL.inline(null, classOf[UInteger]).as("uid"), - DSL.inline(null, classOf[String]).as("userName"), - DSL.inline(null, classOf[String]).as("projects"), - // project attributes: 3 columns - DSL.inline(null, classOf[UInteger]).as("pid"), - DSL.inline(null, classOf[UInteger]).as("owner_id"), - DSL.inline(null, classOf[String]).as("color"), - // file attributes 7 columns - FILE.OWNER_UID, - FILE.FID, - FILE.UPLOAD_TIME, - FILE.PATH, - FILE.SIZE, - USER.EMAIL, - USER_FILE_ACCESS.PRIVILEGE.as("user_file_access") - ) - .from(USER_FILE_ACCESS) - .join(FILE) - .on(USER_FILE_ACCESS.FID.eq(FILE.FID)) - .join(USER) - .on(FILE.OWNER_UID.eq(USER.UID)) - .where(USER_FILE_ACCESS.UID.eq(user.getUid)) - .and( - fileMatchQuery - ) - .and(fileOptionalFilters) - - // Retrieve files to which all shared workflows have access - val sharedWorkflowFileQuery = context - .select( - // common attributes: 4 columns - DSL.inline("file").as("resourceType"), - FILE.NAME, - FILE.DESCRIPTION, - DSL.inline(FILE.UPLOAD_TIME, classOf[Timestamp]).as("creation_time"), - // workflow attributes: 6 columns - DSL.inline(null, classOf[UInteger]).as("wid"), - DSL.inline(FILE.UPLOAD_TIME, classOf[Timestamp]).as("last_modified_time"), - DSL.inline(null, classOf[WorkflowUserAccessPrivilege]).as("privilege"), - DSL.inline(null, classOf[UInteger]).as("uid"), - DSL.inline(null, classOf[String]).as("userName"), - DSL.inline(null, classOf[String]).as("projects"), - // project attributes: 3 columns - DSL.inline(null, classOf[UInteger]).as("pid"), - DSL.inline(null, classOf[UInteger]).as("owner_id"), - DSL.inline(null, classOf[String]).as("color"), - // file attributes 7 columns - FILE.OWNER_UID, - FILE.FID, - FILE.UPLOAD_TIME, - FILE.PATH, - FILE.SIZE, - USER.EMAIL, - DSL.inline(null, classOf[UserFileAccessPrivilege]) - ) - .from(FILE_OF_WORKFLOW) - .join(FILE) - .on(FILE_OF_WORKFLOW.FID.eq(FILE.FID)) - .join(USER) - .on(FILE.OWNER_UID.eq(USER.UID)) - .join(WORKFLOW_USER_ACCESS) - .on(FILE_OF_WORKFLOW.WID.eq(WORKFLOW_USER_ACCESS.WID)) - .where(WORKFLOW_USER_ACCESS.UID.eq(user.getUid)) - .and( - fileMatchQuery - ) - .and(fileOptionalFilters) - - /** - * If there is a need to make changes to `select` statement in any of the 4 subqueries above, make sure to make corresponding changes to the other 3 subqueries as well. - * Synchronizing the changes across all subqueries is important because the columns in every SELECT statement must also be in the same order when using `union`. - * - * To synchronize the changes across all subqueries, follow these steps: - * - * 1. Identify the attribute or column that needs to be added or deleted in one of the subqueries. - * 2. Locate the corresponding select statements in the other three subqueries that retrieve the same resource type. - * 3. Update the select statements in the other subqueries to include the new attribute or exclude the deleted attribute, ensuring that the number and order of columns match the modified subquery. - * 4. Verify that the modified select statements align with the desired response schema. - * - * You can use the sql query I wrote for testing in MySQL workbench. - * SELECT - * 'workflow' AS "resourceType", - * WORKFLOW.NAME, - * WORKFLOW.DESCRIPTION, - * WORKFLOW.CREATION_TIME, - * WORKFLOW.WID, - * WORKFLOW.LAST_MODIFIED_TIME, - * WORKFLOW_USER_ACCESS.PRIVILEGE, - * WORKFLOW_OF_USER.UID, - * USER.NAME as "userName", - * group_concat(WORKFLOW_OF_PROJECT.PID) AS "projects", - * null as pid, - * null as owner_id, - * null as color, - * null as owner_uid, - * null as fid, - * null as upload_time, - * null as path, - * null as size, - * null as email, - * null as user_file_access - * FROM - * WORKFLOW - * LEFT JOIN WORKFLOW_USER_ACCESS ON WORKFLOW_USER_ACCESS.WID = WORKFLOW.WID - * LEFT JOIN WORKFLOW_OF_USER ON WORKFLOW_OF_USER.WID = WORKFLOW.WID - * LEFT JOIN USER ON USER.UID = WORKFLOW_OF_USER.UID - * LEFT JOIN WORKFLOW_OF_PROJECT ON WORKFLOW_OF_PROJECT.WID = WORKFLOW.WID - * WHERE - * WORKFLOW_USER_ACCESS.UID = 1 --make changes accordingly - * - * union - * SELECT - * 'project' AS "resourceType", - * PROJECT.NAME, - * PROJECT.DESCRIPTION, - * PROJECT.CREATION_TIME, - * null, - * null, - * null, - * null, - * null, - * null, - * PROJECT.PID, - * PROJECT.OWNER_ID, - * PROJECT.COLOR, - * null as owner_uid, - * null as fid, - * null as upload_time, - * null as path, - * null as size, - * null as email, - * null as user_file_access - * FROM - * PROJECT - * WHERE - * PROJECT.OWNER_ID = 1 --make changes accordingly - * - * union - * SELECT - * -- common attributes: 4 rows - * 'file' AS "resourceType", - * file.name, - * file.description, - * null, - * -- workflow attributes: 6 rows - * null, - * null, - * null, - * null, - * null, - * null, - * -- project attributes: 3 rows - * null, - * null, - * null, - * -- file attributes 5 rows - * file.owner_uid, - * file.fid, - * file.upload_time, - * file.path, - * file.size, - * user.email, - * USER_FILE_ACCESS.PRIVILEGE as user_file_access - * FROM - * USER_FILE_ACCESS - * JOIN FILE ON USER_FILE_ACCESS.FID = FILE.FID - * JOIN USER ON FILE.OWNER_UID = USER.UID - * WHERE - * USER_FILE_ACCESS.UID = 1 --make changes accordingly - * union - * SELECT - * 'file' AS "resourceType", - * file.name, - * file.description, - * null, - * -- workflow attributes: 6 rows - * null, - * null, - * null, - * null, - * null, - * null, - * -- project attributes: 3 rows - * null, - * null, - * null, - * -- file attributes 5 rows: - * file.owner_uid, - * file.fid, - * file.upload_time, - * file.path, - * file.size, - * user.email, - * null - * FROM - * FILE_OF_WORKFLOW - * JOIN FILE ON FILE_OF_WORKFLOW.FID = FILE.FID - * JOIN USER ON FILE.OWNER_UID = USER.UID - * JOIN WORKFLOW_USER_ACCESS ON FILE_OF_WORKFLOW.WID = WORKFLOW_USER_ACCESS.WID - * WHERE - * WORKFLOW_USER_ACCESS.UID = 1; --make changes accordingly - */ - // Combine all queries using union and fetch results - val clickableFileEntry = - resourceType match { - case "workflow" => - val orderedQuery = orderBy match { - case "NameAsc" => - workflowQuery.orderBy(WORKFLOW.NAME.asc()) - case "NameDesc" => - workflowQuery.orderBy(WORKFLOW.NAME.desc()) - case "CreateTimeDesc" => - workflowQuery - .orderBy(WORKFLOW.CREATION_TIME.desc()) - case "EditTimeDesc" => - workflowQuery - .orderBy(WORKFLOW.LAST_MODIFIED_TIME.desc()) - case _ => - throw new BadRequestException( - "Unknown orderBy. Only 'NameAsc', 'NameDesc', 'CreateTimeDesc', and 'EditTimeDesc' are allowed" - ) - } - orderedQuery - .limit(count + 1) - .offset(offset) - .fetch() - case "project" => - val orderedQuery = orderBy match { - case "NameAsc" => projectQuery.orderBy(PROJECT.NAME.asc()) - case "NameDesc" => projectQuery.orderBy(PROJECT.NAME.desc()) - case "CreateTimeDesc" => projectQuery.orderBy(PROJECT.CREATION_TIME.desc()) - case "EditTimeDesc" => - projectQuery.orderBy( - PROJECT.CREATION_TIME.desc() - ) // use creation_time instead because project doesn't have last_modified_time - case _ => - throw new BadRequestException( - "Unknown orderBy. Only 'NameAsc', 'NameDesc', 'CreateTimeDesc', and 'EditTimeDesc' are allowed" - ) - } - orderedQuery.limit(count + 1).offset(offset).fetch() - case "dataset" => - val orderedQuery = orderBy match { - case "NameAsc" => datasetQuery.orderBy(DATASET.NAME.asc()) - case "NameDesc" => datasetQuery.orderBy(DATASET.NAME.desc()) - case _ => - datasetQuery.orderBy(DATASET.NAME.asc()) - throw new BadRequestException( - "Unknown orderBy. Only 'NameAsc', 'NameDesc' are allowed" - ) - } - orderedQuery.limit(count + 1).offset(offset).fetch() - case "file" => - val orderedQuery = - orderBy match { - case "NameAsc" => - context - .select() - .from(fileQuery.union(sharedWorkflowFileQuery)) - .orderBy(DSL.field("name").asc()) - case "NameDesc" => - context - .select() - .from(fileQuery.union(sharedWorkflowFileQuery)) - .orderBy(DSL.field("name").desc()) - case "CreateTimeDesc" => - context - .select() - .from(fileQuery.union(sharedWorkflowFileQuery)) - .orderBy(DSL.field("upload_time").desc()) - // use upload_time instead because file doesn't have creation_time - case "EditTimeDesc" => - context - .select() - .from(fileQuery.union(sharedWorkflowFileQuery)) - .orderBy(DSL.field("upload_time").desc()) - // use upload_time instead because file doesn't have last_modified_time - case _ => - throw new BadRequestException( - "Unknown orderBy. Only 'NameAsc', 'NameDesc', 'CreateTimeDesc', and 'EditTimeDesc' are allowed" - ) - } - orderedQuery.limit(count + 1).offset(offset).fetch() - case "" => - val unionedTable = - context - .select() - .from( - projectQuery - .union(workflowQuery) - .union(fileQuery) - .union(sharedWorkflowFileQuery) - ) - val orderedQuery = orderBy match { - case "NameAsc" => - unionedTable - .orderBy(DSL.field("name").asc()) - case "NameDesc" => - unionedTable - .orderBy(DSL.field("name").desc()) - case "CreateTimeDesc" => - unionedTable - .orderBy(DSL.field("creation_time").desc()) - case "EditTimeDesc" => - unionedTable - .orderBy(DSL.field("last_modified_time").desc()) - case _ => - throw new BadRequestException( - "Unknown orderBy. Only 'NameAsc', 'NameDesc', 'CreateTimeDesc', and 'EditTimeDesc' are allowed" - ) - } - orderedQuery.limit(count + 1).offset(offset).fetch() - - case _ => - throw new BadRequestException( - "Unknown resourceType. Only 'workflow', 'project', and 'file' are allowed" - ) - } - val moreRecords = clickableFileEntry.size() > count - DashboardSearchResult( - results = clickableFileEntry.asScala - .take(count) - .map(record => { - val resourceType = record.get("resourceType", classOf[String]) - DashboardClickableFileEntry( - resourceType, - if (resourceType == "workflow") { - DashboardWorkflow( - record.into(WORKFLOW_OF_USER).getUid.eq(user.getUid), - record - .into(WORKFLOW_USER_ACCESS) - .into(classOf[WorkflowUserAccess]) - .getPrivilege - .toString, - record.into(USER).getName, - record.into(WORKFLOW).into(classOf[Workflow]), - if (record.get("projects") == null) { - List[UInteger]() - } else { - record - .get("projects") - .asInstanceOf[String] - .split(',') - .map(number => UInteger.valueOf(number)) - .toList - } - ) - } else { - null - }, - if (resourceType == "project") { - record.into(PROJECT).into(classOf[Project]) - } else { - null - }, - if (resourceType == "file") { - DashboardFile( - record.into(USER).getEmail, - record - .get( - "user_file_access", - classOf[UserFileAccessPrivilege] - ) - .toString, - record.into(FILE).into(classOf[File]) - ) - } else { - null - }, - if (resourceType == "dataset") { - val dataset = record.into(DATASET).into(classOf[Dataset]) - val datasetOfUserUid = record.into(DATASET_USER_ACCESS).getUid - var accessLevel = record.into(DATASET_USER_ACCESS).getPrivilege - if (datasetOfUserUid != user.getUid) { - accessLevel = DatasetUserAccessPrivilege.READ - } - if (dataset.getOwnerUid == user.getUid) { - accessLevel = DatasetUserAccessPrivilege.WRITE - } - DashboardDataset( - dataset, - accessLevel, - dataset.getOwnerUid == user.getUid - ) - } else { - null - } - ) - }) - .toList, - more = moreRecords - ) - + DashboardResource.searchAllResources(user, params) } - } diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/FileSearchQueryBuilder.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/FileSearchQueryBuilder.scala new file mode 100644 index 00000000000..00a510c8967 --- /dev/null +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/FileSearchQueryBuilder.scala @@ -0,0 +1,87 @@ +package edu.uci.ics.texera.web.resource.dashboard +import edu.uci.ics.texera.web.model.jooq.generated.Tables.{FILE, USER, USER_FILE_ACCESS} +import edu.uci.ics.texera.web.model.jooq.generated.enums.UserFileAccessPrivilege +import edu.uci.ics.texera.web.model.jooq.generated.tables.pojos.File +import edu.uci.ics.texera.web.resource.dashboard.DashboardResource.DashboardClickableFileEntry +import edu.uci.ics.texera.web.resource.dashboard.FulltextSearchQueryUtils.{ + getFullTextSearchFilter, + getSubstringSearchFilter, + getContainsFilter, + getDateFilter +} +import edu.uci.ics.texera.web.resource.dashboard.user.file.UserFileResource.DashboardFile +import org.jooq.{Condition, GroupField, Record, TableLike} +import org.jooq.impl.DSL +import org.jooq.types.UInteger + +import scala.jdk.CollectionConverters.CollectionHasAsScala +object FileSearchQueryBuilder extends SearchQueryBuilder { + + override val mappedResourceSchema: UnifiedResourceSchema = UnifiedResourceSchema( + resourceType = DSL.inline(SearchQueryBuilder.FILE_RESOURCE_TYPE), + name = FILE.NAME, + description = FILE.DESCRIPTION, + creationTime = FILE.UPLOAD_TIME, + fid = FILE.FID, + ownerId = FILE.OWNER_UID, + lastModifiedTime = FILE.UPLOAD_TIME, + filePath = FILE.PATH, + fileSize = FILE.SIZE, + userEmail = USER.EMAIL, + fileUserAccess = USER_FILE_ACCESS.PRIVILEGE + ) + + override protected def constructFromClause( + uid: UInteger, + params: DashboardResource.SearchQueryParams + ): TableLike[_] = { + FILE + .leftJoin(USER_FILE_ACCESS) + .on(USER_FILE_ACCESS.FID.eq(FILE.FID)) + .leftJoin(USER) + .on(FILE.OWNER_UID.eq(USER.UID)) + .where(USER_FILE_ACCESS.UID.eq(uid)) + } + + override protected def constructWhereClause( + uid: UInteger, + params: DashboardResource.SearchQueryParams + ): Condition = { + val splitKeywords = params.keywords.asScala + .flatMap(_.split("[+\\-()<>~*@\"]")) + .filter(_.nonEmpty) + .toSeq + + getDateFilter( + params.creationStartDate, + params.creationEndDate, + FILE.UPLOAD_TIME + ) + .and(getContainsFilter(params.owners, USER.EMAIL)) + .and( + getFullTextSearchFilter( + splitKeywords, + List(FILE.NAME, FILE.DESCRIPTION) + ).or(getSubstringSearchFilter(splitKeywords, List(FILE.NAME, FILE.DESCRIPTION))) + ) + } + + override protected def getGroupByFields: Seq[GroupField] = Seq.empty + + override def toEntryImpl( + uid: UInteger, + record: Record + ): DashboardResource.DashboardClickableFileEntry = { + val df = DashboardFile( + record.into(USER).getEmail, + record + .get( + USER_FILE_ACCESS.PRIVILEGE, + classOf[UserFileAccessPrivilege] + ) + .toString, + record.into(FILE).into(classOf[File]) + ) + DashboardClickableFileEntry(SearchQueryBuilder.FILE_RESOURCE_TYPE, file = Some(df)) + } +} diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/FulltextSearchQueryUtils.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/FulltextSearchQueryUtils.scala new file mode 100644 index 00000000000..cc9f28eb765 --- /dev/null +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/FulltextSearchQueryUtils.scala @@ -0,0 +1,127 @@ +package edu.uci.ics.texera.web.resource.dashboard + +import org.jooq.{Condition, Field} +import org.jooq.impl.DSL.{condition, noCondition} + +import java.sql.Timestamp +import java.text.{ParseException, SimpleDateFormat} +import java.util.concurrent.TimeUnit +import scala.jdk.CollectionConverters.CollectionHasAsScala + +object FulltextSearchQueryUtils { + + def getFullTextSearchFilter( + keywords: Seq[String], + fields: List[Field[String]] + ): Condition = { + if (fields.isEmpty) return noCondition() + val trimmedKeywords = keywords.filter(_.nonEmpty).map(_.trim) + val fullFieldNames = fields.map(_.toString.replace("\"", "")) + val indexedCompoundFields = fullFieldNames.mkString(",") + trimmedKeywords.foldLeft(noCondition()) { (acc, key) => + val words = key.split("\\s+") + acc.and( + condition( + s"MATCH($indexedCompoundFields) AGAINST('${words.mkString("+", " +", "")}' IN BOOLEAN MODE)", + key + ) + ) + } + } + + def getSubstringSearchFilter( + keywords: Seq[String], + fields: List[Field[String]] + ): Condition = { + if (fields.isEmpty) return noCondition() + val trimmedKeywords = keywords.filter(_.nonEmpty).map(_.trim) + val fullFieldNames = fields.map(_.toString.replace("\"", "")) + fullFieldNames.foldLeft(noCondition()) { (acc, fieldName) => + acc.or(trimmedKeywords.foldLeft(noCondition()) { (accInner, key) => + accInner.and(s"$fieldName LIKE '%$key%'") + }) + } + } + + /** + * Generates a filter condition for querying based on whether a specified field contains any of the given values. + * + * This method converts a Java list of values into a Scala set to ensure uniqueness, and then iterates over each unique value, + * constructing a filter condition that checks if the specified field equals any of those values. The resulting condition + * is a disjunction (`OR`) of all these equality conditions, which can be used in database queries to find records where + * the field matches any of the provided values. + * + * @tparam T The type of the elements in the `values` list and the type of the field being compared. + * @param values A Java list of values to be checked against the field. The list is converted to a Scala set to remove duplicates. + * @param field The field to be checked for containing any of the values in the `values` list. This is typically a field in a database table. + * @return A `Condition` that represents the disjunction of equality checks between the field and each unique value in the input list. + * This condition can be used as part of a query to select records where the field matches any of the specified values. + */ + def getContainsFilter[T](values: java.util.List[T], field: Field[T]): Condition = { + val valueSet = values.asScala.toSet + var filterForOneField: Condition = noCondition() + for (value <- valueSet) { + filterForOneField = filterForOneField.or(field.eq(value)) + } + filterForOneField + } + + /** + * Returns a date filter condition for the specified date range and date type. + * + * @param startDate A string representing the start date of the filter range in "yyyy-MM-dd" format. + * If empty, the default value "1970-01-01" will be used. + * @param endDate A string representing the end date of the filter range in "yyyy-MM-dd" format. + * If empty, the default value "9999-12-31" will be used. + * @param fieldToFilterOn the field for applying the start and end dates. + * @return A Condition object that can be used to filter workflows based on the date range and type. + */ + @throws[ParseException] + def getDateFilter( + startDate: String, + endDate: String, + fieldToFilterOn: Field[Timestamp] + ): Condition = { + if (startDate.nonEmpty || endDate.nonEmpty) { + val start = if (startDate.nonEmpty) startDate else "1970-01-01" + val end = if (endDate.nonEmpty) endDate else "9999-12-31" + val dateFormat = new SimpleDateFormat("yyyy-MM-dd") + + val startTimestamp = new Timestamp(dateFormat.parse(start).getTime) + val endTimestamp = + if (end == "9999-12-31") { + new Timestamp(dateFormat.parse(end).getTime) + } else { + new Timestamp( + dateFormat.parse(end).getTime + TimeUnit.DAYS.toMillis(1) - 1 + ) + } + fieldToFilterOn.between(startTimestamp, endTimestamp) + } else { + noCondition() + } + } + + /** + * Helper function to retrieve the operators filter. + * Applies a filter based on the specified operators. + * + * @param operators The list of operators to filter by. + * @return The operators filter. + */ + def getOperatorsFilter( + operators: java.util.List[String], + field: Field[String] + ): Condition = { + val operatorSet = operators.asScala.toSet + var fieldFilter = noCondition() + for (operator <- operatorSet) { + val quotes = "\"" + val searchKey = + "%" + quotes + "operatorType" + quotes + ":" + quotes + operator + quotes + "%" + fieldFilter = fieldFilter.or(field.likeIgnoreCase(searchKey)) + } + fieldFilter + } + +} diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/ProjectSearchQueryBuilder.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/ProjectSearchQueryBuilder.scala new file mode 100644 index 00000000000..c6860348d89 --- /dev/null +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/ProjectSearchQueryBuilder.scala @@ -0,0 +1,74 @@ +package edu.uci.ics.texera.web.resource.dashboard +import edu.uci.ics.texera.web.model.jooq.generated.Tables.{PROJECT, PROJECT_USER_ACCESS} +import edu.uci.ics.texera.web.model.jooq.generated.tables.pojos.Project +import edu.uci.ics.texera.web.resource.dashboard.DashboardResource.DashboardClickableFileEntry +import edu.uci.ics.texera.web.resource.dashboard.FulltextSearchQueryUtils.{ + getFullTextSearchFilter, + getSubstringSearchFilter, + getContainsFilter, + getDateFilter +} +import org.jooq.{Condition, GroupField, Record, TableLike} +import org.jooq.impl.DSL +import org.jooq.types.UInteger + +import scala.jdk.CollectionConverters.CollectionHasAsScala +object ProjectSearchQueryBuilder extends SearchQueryBuilder { + + override val mappedResourceSchema: UnifiedResourceSchema = UnifiedResourceSchema( + resourceType = DSL.inline(SearchQueryBuilder.PROJECT_RESOURCE_TYPE), + name = PROJECT.NAME, + description = PROJECT.DESCRIPTION, + creationTime = PROJECT.CREATION_TIME, + lastModifiedTime = PROJECT.CREATION_TIME, + pid = PROJECT.PID, + ownerId = PROJECT.OWNER_ID, + projectColor = PROJECT.COLOR + ) + + override protected def constructFromClause( + uid: UInteger, + params: DashboardResource.SearchQueryParams + ): TableLike[_] = { + PROJECT + .leftJoin(PROJECT_USER_ACCESS) + .on(PROJECT_USER_ACCESS.PID.eq(PROJECT.PID)) + .where(PROJECT_USER_ACCESS.UID.eq(uid)) + } + + override protected def constructWhereClause( + uid: UInteger, + params: DashboardResource.SearchQueryParams + ): Condition = { + val splitKeywords = params.keywords.asScala + .flatMap(_.split("[+\\-()<>~*@\"]")) + .filter(_.nonEmpty) + .toSeq + + getDateFilter( + params.creationStartDate, + params.creationEndDate, + PROJECT.CREATION_TIME + ) + .and(getContainsFilter(params.projectIds, PROJECT.PID)) + .and( + getFullTextSearchFilter(splitKeywords, List(PROJECT.NAME, PROJECT.DESCRIPTION)) + .or( + getSubstringSearchFilter( + splitKeywords, + List(PROJECT.NAME, PROJECT.DESCRIPTION) + ) + ) + ) + } + + override protected def getGroupByFields: Seq[GroupField] = Seq.empty + + override def toEntryImpl( + uid: UInteger, + record: Record + ): DashboardResource.DashboardClickableFileEntry = { + val dp = record.into(PROJECT).into(classOf[Project]) + DashboardClickableFileEntry(SearchQueryBuilder.PROJECT_RESOURCE_TYPE, project = Some(dp)) + } +} diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/SearchQueryBuilder.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/SearchQueryBuilder.scala new file mode 100644 index 00000000000..3128d6f2eaa --- /dev/null +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/SearchQueryBuilder.scala @@ -0,0 +1,54 @@ +package edu.uci.ics.texera.web.resource.dashboard + +import edu.uci.ics.texera.web.SqlServer +import edu.uci.ics.texera.web.resource.dashboard.DashboardResource.{ + DashboardClickableFileEntry, + SearchQueryParams +} +import edu.uci.ics.texera.web.resource.dashboard.SearchQueryBuilder.context +import org.jooq.types.UInteger +import org.jooq.{Condition, GroupField, Record, SelectGroupByStep, SelectHavingStep, TableLike} +object SearchQueryBuilder { + + final lazy val context = SqlServer.createDSLContext() + val FILE_RESOURCE_TYPE = "file" + val WORKFLOW_RESOURCE_TYPE = "workflow" + val PROJECT_RESOURCE_TYPE = "project" + val ALL_RESOURCE_TYPE = "" +} + +trait SearchQueryBuilder { + + protected val mappedResourceSchema: UnifiedResourceSchema + + protected def constructFromClause(uid: UInteger, params: SearchQueryParams): TableLike[_] + + protected def constructWhereClause(uid: UInteger, params: SearchQueryParams): Condition + + protected def getGroupByFields: Seq[GroupField] = Seq.empty + + protected def toEntryImpl(uid: UInteger, record: Record): DashboardClickableFileEntry + + private def translateRecord(record: Record): Record = mappedResourceSchema.translateRecord(record) + + def toEntry(uid: UInteger, record: Record): DashboardClickableFileEntry = { + toEntryImpl(uid, translateRecord(record)) + } + + final def constructQuery( + uid: UInteger, + params: SearchQueryParams + ): SelectHavingStep[Record] = { + val query: SelectGroupByStep[Record] = context + .select(mappedResourceSchema.allFields: _*) + .from(constructFromClause(uid, params)) + .where(constructWhereClause(uid, params)) + val groupByFields = getGroupByFields + if (groupByFields.nonEmpty) { + query.groupBy(groupByFields: _*) + } else { + query + } + } + +} diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/UnifiedResourceSchema.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/UnifiedResourceSchema.scala new file mode 100644 index 00000000000..776e705d6d5 --- /dev/null +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/UnifiedResourceSchema.scala @@ -0,0 +1,144 @@ +package edu.uci.ics.texera.web.resource.dashboard + +import edu.uci.ics.texera.web.SqlServer +import edu.uci.ics.texera.web.model.jooq.generated.enums.{ + UserFileAccessPrivilege, + WorkflowUserAccessPrivilege +} +import edu.uci.ics.texera.web.resource.dashboard.UnifiedResourceSchema.context +import org.jooq.impl.DSL +import org.jooq.types.UInteger +import org.jooq.{Field, Record} + +import java.sql.Timestamp +import scala.collection.mutable + +object UnifiedResourceSchema { + + // Define alias strings + private val resourceTypeAlias = "resourceType" + private val resourceNameAlias = "resourceName" + private val resourceDescriptionAlias = "resourceDescription" + private val resourceCreationTimeAlias = "resourceCreationTime" + private val resourceOwnerIdAlias = "resourceOwnerId" + private val resourceLastModifiedTimeAlias = "resourceLastModifiedTime" + + // Use the alias variables to create fields + val resourceTypeField: Field[_] = DSL.field(DSL.name(resourceTypeAlias)) + val resourceNameField: Field[_] = DSL.field(DSL.name(resourceNameAlias)) + val resourceDescriptionField: Field[_] = DSL.field(DSL.name(resourceDescriptionAlias)) + val resourceCreationTimeField: Field[_] = DSL.field(DSL.name(resourceCreationTimeAlias)) + val resourceOwnerIdField: Field[_] = DSL.field(DSL.name(resourceOwnerIdAlias)) + val resourceLastModifiedTimeField: Field[_] = DSL.field(DSL.name(resourceLastModifiedTimeAlias)) + + final lazy val context = SqlServer.createDSLContext() + def apply( + resourceType: Field[String] = DSL.inline(""), + name: Field[String] = DSL.inline(""), + description: Field[String] = DSL.inline(""), + creationTime: Field[Timestamp] = DSL.inline(null, classOf[Timestamp]), + lastModifiedTime: Field[Timestamp] = DSL.inline(null, classOf[Timestamp]), + ownerId: Field[UInteger] = DSL.inline(null, classOf[UInteger]), + wid: Field[UInteger] = DSL.inline(null, classOf[UInteger]), + workflowUserAccess: Field[WorkflowUserAccessPrivilege] = + DSL.inline(null, classOf[WorkflowUserAccessPrivilege]), + projectsOfWorkflow: Field[String] = DSL.inline(""), + uid: Field[UInteger] = DSL.inline(null, classOf[UInteger]), + userName: Field[String] = DSL.inline(""), + userEmail: Field[String] = DSL.inline(""), + pid: Field[UInteger] = DSL.inline(null, classOf[UInteger]), + projectOwnerId: Field[UInteger] = DSL.inline(null, classOf[UInteger]), + projectColor: Field[String] = DSL.inline(""), + fid: Field[UInteger] = DSL.inline(null, classOf[UInteger]), + fileUploadTime: Field[Timestamp] = DSL.inline(null, classOf[Timestamp]), + filePath: Field[String] = DSL.inline(""), + fileSize: Field[UInteger] = DSL.inline(null, classOf[UInteger]), + fileUserAccess: Field[UserFileAccessPrivilege] = + DSL.inline(null, classOf[UserFileAccessPrivilege]) + ): UnifiedResourceSchema = { + new UnifiedResourceSchema( + Seq( + resourceType -> resourceType.as(resourceTypeAlias), + name -> name.as(resourceNameAlias), + description -> description.as(resourceDescriptionAlias), + creationTime -> creationTime.as(resourceCreationTimeAlias), + lastModifiedTime -> lastModifiedTime.as(resourceLastModifiedTimeAlias), + ownerId -> ownerId.as(resourceOwnerIdAlias), + wid -> wid.as("wid"), + workflowUserAccess -> workflowUserAccess.as("workflow_privilege"), + projectsOfWorkflow -> projectsOfWorkflow.as("projects"), + uid -> uid.as("uid"), + userName -> userName.as("userName"), + userEmail -> userEmail.as("email"), + pid -> pid.as("pid"), + projectOwnerId -> projectOwnerId.as("owner_uid"), + projectColor -> projectColor.as("color"), + fid -> fid.as("fid"), + fileUploadTime -> fileUploadTime.as("upload_time"), + filePath -> filePath.as("path"), + fileSize -> fileSize.as("size"), + fileUserAccess -> fileUserAccess.as("user_file_access") + ) + ) + } +} + +/** + * Refer to texera/core/scripts/sql/texera_ddl.sql to understand what each attribute is + * + * Attributes common across all resource types: + * - `resourceType`: The type of the resource (e.g., project, workflow, file) as a `String`. + * - `name`: The name of the resource as a `String`. + * - `description`: A textual description of the resource as a `String`. + * - `creationTime`: The timestamp when the resource was created, as a `Timestamp`. + * - `lastModifiedTime`: The timestamp of the last modification to the resource, as a `Timestamp` (applicable to workflows). + * - `ownerId`: The identifier of the resource's owner, as a `UInteger`. + * + * Attributes specific to workflows: + * - `wid`: Workflow ID, as a `UInteger`. + * - `workflowUserAccess`: Access privileges associated with the workflow, as a `WorkflowUserAccessPrivilege`. + * - `projectsOfWorkflow`: IDs of projects associated with the workflow, concatenated as a `String`. + * - `uid`: User ID associated with the workflow, as a `UInteger`. + * - `userName`: Name of the user associated with the workflow, as a `String`. + * - `userEmail`: Email of the user associated with the workflow, as a `String`. + * + * Attributes specific to projects: + * - `pid`: Project ID, as a `UInteger`. + * - `projectOwnerId`: ID of the project owner, as a `UInteger`. + * - `projectColor`: Color associated with the project, as a `String`. + * + * Attributes specific to files: + * - `fid`: File ID, as a `UInteger`. + * - `fileUploadTime`: Timestamp when the file was uploaded, as a `Timestamp`. + * - `filePath`: Path of the file, as a `String`. + * - `fileSize`: Size of the file, as a `UInteger`. + * - `fileUserAccess`: Access privileges for the file, as a `UserFileAccessPrivilege`. + */ +class UnifiedResourceSchema private ( + fieldMappingSeq: Seq[(Field[_], Field[_])] +) { + val allFields: Seq[Field[_]] = fieldMappingSeq.map(_._2) + + private val translatedFieldSet: Seq[(Field[_], Field[_])] = { + val addedFields = new mutable.HashSet[Field[_]]() + val output = new mutable.ArrayBuffer[(Field[_], Field[_])]() + fieldMappingSeq.foreach { + case (original, translated) => + if (!addedFields.contains(original)) { + addedFields.add(original) + output.addOne((original, translated)) + } + } + output.toSeq + } + + def translateRecord(record: Record): Record = { + val ret = context.newRecord(translatedFieldSet.map(_._1): _*) + translatedFieldSet.foreach { + case (original, translated) => + ret.set(original.asInstanceOf[org.jooq.Field[Any]], record.get(translated)) + } + ret + } + +} diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/WorkflowSearchQueryBuilder.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/WorkflowSearchQueryBuilder.scala new file mode 100644 index 00000000000..9b3dd8ee769 --- /dev/null +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/WorkflowSearchQueryBuilder.scala @@ -0,0 +1,138 @@ +package edu.uci.ics.texera.web.resource.dashboard + +import edu.uci.ics.texera.web.model.jooq.generated.Tables.{ + PROJECT_USER_ACCESS, + USER, + WORKFLOW, + WORKFLOW_OF_PROJECT, + WORKFLOW_OF_USER, + WORKFLOW_USER_ACCESS +} +import edu.uci.ics.texera.web.model.jooq.generated.tables.pojos.Workflow +import edu.uci.ics.texera.web.resource.dashboard.DashboardResource.DashboardClickableFileEntry +import edu.uci.ics.texera.web.resource.dashboard.FulltextSearchQueryUtils.{ + getFullTextSearchFilter, + getSubstringSearchFilter, + getContainsFilter, + getDateFilter, + getOperatorsFilter +} +import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowResource.DashboardWorkflow +import org.jooq.{Condition, GroupField, Record, TableLike} +import org.jooq.impl.DSL +import org.jooq.impl.DSL.groupConcatDistinct +import org.jooq.types.UInteger + +import scala.jdk.CollectionConverters.CollectionHasAsScala + +object WorkflowSearchQueryBuilder extends SearchQueryBuilder { + + override val mappedResourceSchema: UnifiedResourceSchema = { + UnifiedResourceSchema( + resourceType = DSL.inline(SearchQueryBuilder.WORKFLOW_RESOURCE_TYPE), + name = WORKFLOW.NAME, + description = WORKFLOW.DESCRIPTION, + creationTime = WORKFLOW.CREATION_TIME, + wid = WORKFLOW.WID, + lastModifiedTime = WORKFLOW.LAST_MODIFIED_TIME, + workflowUserAccess = WORKFLOW_USER_ACCESS.PRIVILEGE, + uid = WORKFLOW_OF_USER.UID, + ownerId = WORKFLOW_OF_USER.UID, + userName = USER.NAME, + projectsOfWorkflow = groupConcatDistinct(WORKFLOW_OF_PROJECT.PID) + ) + } + + override protected def constructFromClause( + uid: UInteger, + params: DashboardResource.SearchQueryParams + ): TableLike[_] = { + WORKFLOW + .leftJoin(WORKFLOW_USER_ACCESS) + .on(WORKFLOW_USER_ACCESS.WID.eq(WORKFLOW.WID)) + .leftJoin(WORKFLOW_OF_USER) + .on(WORKFLOW_OF_USER.WID.eq(WORKFLOW.WID)) + .leftJoin(USER) + .on(USER.UID.eq(WORKFLOW_OF_USER.UID)) + .leftJoin(WORKFLOW_OF_PROJECT) + .on(WORKFLOW_OF_PROJECT.WID.eq(WORKFLOW.WID)) + .leftJoin(PROJECT_USER_ACCESS) + .on(PROJECT_USER_ACCESS.PID.eq(WORKFLOW_OF_PROJECT.PID)) + .where( + WORKFLOW_USER_ACCESS.UID.eq(uid).or(PROJECT_USER_ACCESS.UID.eq(uid)) + ) + } + + override protected def constructWhereClause( + uid: UInteger, + params: DashboardResource.SearchQueryParams + ): Condition = { + val splitKeywords = params.keywords.asScala + .flatMap(_.split("[+\\-()<>~*@\"]")) + .filter(_.nonEmpty) + .toSeq + getDateFilter( + params.creationStartDate, + params.creationEndDate, + WORKFLOW.CREATION_TIME + ) + // Apply lastModified_time date filter + .and( + getDateFilter( + params.modifiedStartDate, + params.modifiedEndDate, + WORKFLOW.LAST_MODIFIED_TIME + ) + ) + // Apply workflowID filter + .and(getContainsFilter(params.workflowIDs, WORKFLOW.WID)) + // Apply owner filter + .and(getContainsFilter(params.owners, USER.EMAIL)) + // Apply operators filter + .and(getOperatorsFilter(params.operators, WORKFLOW.CONTENT)) + // Apply projectId filter + .and(getContainsFilter(params.projectIds, WORKFLOW_OF_PROJECT.PID)) + // Apply fulltext search filter + .and( + getFullTextSearchFilter( + splitKeywords, + List(WORKFLOW.NAME, WORKFLOW.DESCRIPTION, WORKFLOW.CONTENT) + ).or( + getSubstringSearchFilter( + splitKeywords, + List(WORKFLOW.NAME, WORKFLOW.DESCRIPTION, WORKFLOW.CONTENT) + ) + ) + ) + } + + override protected def getGroupByFields: Seq[GroupField] = { + Seq(WORKFLOW.WID) + } + + override def toEntryImpl( + uid: UInteger, + record: Record + ): DashboardResource.DashboardClickableFileEntry = { + val pidField = groupConcatDistinct(WORKFLOW_OF_PROJECT.PID) + val dw = DashboardWorkflow( + record.into(WORKFLOW_OF_USER).getUid.eq(uid), + record + .get(WORKFLOW_USER_ACCESS.PRIVILEGE) + .toString, + record.into(USER).getName, + record.into(WORKFLOW).into(classOf[Workflow]), + if (record.get(pidField) == null) { + List[UInteger]() + } else { + record + .get(pidField) + .asInstanceOf[String] + .split(',') + .map(number => UInteger.valueOf(number)) + .toList + } + ) + DashboardClickableFileEntry(SearchQueryBuilder.WORKFLOW_RESOURCE_TYPE, workflow = Some(dw)) + } +} diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/project/ProjectResource.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/project/ProjectResource.scala index c9ebb05c65c..0382d17c9d2 100644 --- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/project/ProjectResource.scala +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/project/ProjectResource.scala @@ -11,6 +11,8 @@ import edu.uci.ics.texera.web.model.jooq.generated.tables.daos.{ WorkflowOfProjectDao } import edu.uci.ics.texera.web.model.jooq.generated.tables.pojos._ +import edu.uci.ics.texera.web.resource.dashboard.DashboardResource +import edu.uci.ics.texera.web.resource.dashboard.DashboardResource.SearchQueryParams import edu.uci.ics.texera.web.resource.dashboard.user.file.UserFileResource.DashboardFile import edu.uci.ics.texera.web.resource.dashboard.user.project.ProjectResource._ import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowAccessResource.hasReadAccess @@ -161,34 +163,11 @@ class ProjectResource { @PathParam("pid") pid: UInteger, @Auth user: SessionUser ): List[DashboardWorkflow] = { - context - .select() - .from(WORKFLOW_OF_PROJECT) - .join(WORKFLOW) - .on(WORKFLOW.WID.eq(WORKFLOW_OF_PROJECT.WID)) - .join(WORKFLOW_USER_ACCESS) - .on(WORKFLOW_USER_ACCESS.WID.eq(WORKFLOW_OF_PROJECT.WID)) - .join(WORKFLOW_OF_USER) - .on(WORKFLOW_OF_USER.WID.eq(WORKFLOW_OF_PROJECT.WID)) - .join(USER) - .on(USER.UID.eq(WORKFLOW_OF_USER.UID)) - .where(WORKFLOW_OF_PROJECT.PID.eq(pid)) - .fetch() - .map(workflowRecord => - DashboardWorkflow( - workflowRecord.into(WORKFLOW_OF_USER).getUid.eq(user.getUid), - workflowRecord - .into(WORKFLOW_USER_ACCESS) - .into(classOf[WorkflowUserAccess]) - .getPrivilege - .toString, - workflowRecord.into(USER).getName, - workflowRecord.into(WORKFLOW).into(classOf[Workflow]), - List() - ) - ) - .asScala - .toList + val result = DashboardResource.searchAllResources( + user, + SearchQueryParams(resourceType = "workflow", projectIds = util.Arrays.asList(pid)) + ) + result.results.map(_.workflow.get) } /** diff --git a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala index 032b3362d6b..87354aa7f5a 100644 --- a/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala +++ b/core/amber/src/main/scala/edu/uci/ics/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala @@ -15,18 +15,15 @@ import edu.uci.ics.texera.web.model.jooq.generated.tables.pojos._ import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowAccessResource.hasReadAccess import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowResource._ import io.dropwizard.auth.Auth -import org.jooq.{Condition, TableField} +import org.jooq.Condition import org.jooq.impl.DSL.{groupConcatDistinct, noCondition} import org.jooq.types.UInteger import java.sql.Timestamp -import java.text.{ParseException, SimpleDateFormat} import java.util -import java.util.concurrent.TimeUnit import javax.annotation.security.RolesAllowed import javax.ws.rs._ import javax.ws.rs.core.MediaType -import scala.collection.mutable import scala.collection.mutable.ListBuffer import scala.jdk.CollectionConverters.IterableHasAsScala import scala.util.control.NonFatal @@ -96,162 +93,6 @@ object WorkflowResource { case class WorkflowIDs(wids: List[UInteger], pid: Option[UInteger]) - def createWorkflowFilterCondition( - creationStartDate: String, - creationEndDate: String, - modifiedStartDate: String, - modifiedEndDate: String, - workflowIDs: java.util.List[UInteger], - owners: java.util.List[String], - operators: java.util.List[String], - projectIds: java.util.List[UInteger] - ): Condition = { - noCondition() - // Apply creation_time date filter - .and(getDateFilter(creationStartDate, creationEndDate, WORKFLOW.CREATION_TIME)) - // Apply lastModified_time date filter - .and(getDateFilter(modifiedStartDate, modifiedEndDate, WORKFLOW.LAST_MODIFIED_TIME)) - // Apply workflowID filter - .and(getWorkflowIdFilter(workflowIDs)) - // Apply owner filter - .and(getOwnerFilter(owners)) - // Apply operators filter - .and(getOperatorsFilter(operators)) - // Apply projectId filter - .and(getProjectFilter(projectIds, WORKFLOW_OF_PROJECT.PID)) - } - - /** - * Helper function to retrieve the owner filter. - * Applies a filter based on the specified owner emails. - * - * @param owners The list of owner emails to filter by. - * @return The owner filter. - */ - def getOwnerFilter(owners: java.util.List[String]): Condition = { - var ownerFilter: Condition = noCondition() - val ownerSet: mutable.Set[String] = mutable.Set() - if (owners != null && !owners.isEmpty) { - for (owner <- owners.asScala) { - if (!ownerSet(owner)) { - ownerSet += owner - ownerFilter = ownerFilter.or(USER.EMAIL.eq(owner)) - } - } - } - ownerFilter - } - - /** - * Helper function to retrieve the project filter. - * Applies a filter based on the specified project IDs. - * - * @param projectIds The list of owner names to filter by. - * @param fieldToFilterOn the field for applying the project ids. - * @return The projectId filter. - */ - def getProjectFilter( - projectIds: java.util.List[UInteger], - fieldToFilterOn: TableField[_, UInteger] - ): Condition = { - var projectIdFilter: Condition = noCondition() - val projectIdSet: mutable.Set[UInteger] = mutable.Set() - if (projectIds != null && projectIds.asScala.nonEmpty) { - for (projectId <- projectIds.asScala) { - if (!projectIdSet(projectId)) { - projectIdSet += projectId - projectIdFilter = projectIdFilter.or(fieldToFilterOn.eq(projectId)) - } - } - } - projectIdFilter - } - - /** - * Helper function to retrieve the workflowID filter. - * Applies a filter based on the specified workflow IDs. - * - * @param workflowIDs The list of workflow IDs to filter by. - * @return The workflowID filter. - */ - def getWorkflowIdFilter(workflowIDs: java.util.List[UInteger]): Condition = { - var workflowIdFilter: Condition = noCondition() - val workflowIdSet: mutable.Set[UInteger] = mutable.Set() - if (workflowIDs != null && !workflowIDs.isEmpty) { - for (workflowID <- workflowIDs.asScala) { - if (!workflowIdSet(workflowID)) { - workflowIdSet += workflowID - workflowIdFilter = workflowIdFilter.or(WORKFLOW.WID.eq(workflowID)) - - } - } - } - workflowIdFilter - } - - /** - * Returns a date filter condition for the specified date range and date type. - * - * @param startDate A string representing the start date of the filter range in "yyyy-MM-dd" format. - * If empty, the default value "1970-01-01" will be used. - * @param endDate A string representing the end date of the filter range in "yyyy-MM-dd" format. - * If empty, the default value "9999-12-31" will be used. - * @param fieldToFilterOn the field for applying the start and end dates. - * @return A Condition object that can be used to filter workflows based on the date range and type. - */ - @throws[ParseException] - def getDateFilter( - startDate: String, - endDate: String, - fieldToFilterOn: TableField[_, Timestamp] - ): Condition = { - var dateFilter: Condition = noCondition() - - if (startDate.nonEmpty || endDate.nonEmpty) { - val start = if (startDate.nonEmpty) startDate else "1970-01-01" - val end = if (endDate.nonEmpty) endDate else "9999-12-31" - val dateFormat = new SimpleDateFormat("yyyy-MM-dd") - - val startTimestamp = new Timestamp(dateFormat.parse(start).getTime) - val endTimestamp = - if (end == "9999-12-31") { - new Timestamp(dateFormat.parse(end).getTime) - } else { - new Timestamp( - dateFormat.parse(end).getTime + TimeUnit.DAYS.toMillis(1) - 1 - ) - } - dateFilter = fieldToFilterOn.between(startTimestamp, endTimestamp) - - } - dateFilter - } - - /** - * Helper function to retrieve the operators filter. - * Applies a filter based on the specified operators. - * - * @param operators The list of operators to filter by. - * @return The operators filter. - */ - def getOperatorsFilter(operators: java.util.List[String]): Condition = { - var operatorsFilter: Condition = noCondition() - if (operators != null && operators.asScala.nonEmpty) { - for (operator <- operators.asScala) { - val quotes = "\"" - val searchKey = - "%" + quotes + "operatorType" + quotes + ":" + quotes + s"$operator" + quotes + "%" - operatorsFilter = operatorsFilter.or( - WORKFLOW.CONTENT - .likeIgnoreCase( - searchKey - ) - ) - } - } - operatorsFilter - } - } @Produces(Array(MediaType.APPLICATION_JSON)) @RolesAllowed(Array("REGULAR", "ADMIN")) @@ -614,138 +455,4 @@ class WorkflowResource extends LazyLogging { } } - /** - * This method performs a full-text search in the content column of the - * workflow table for workflows that match the specified keywords. - * - * This method utilizes MySQL Boolean Full-Text Searches - * reference: https://dev.mysql.com/doc/refman/8.0/en/fulltext-boolean.html - * - * @param sessionUser The authenticated user. - * @param keywords The search keywords. - * @return A list of workflows that match the search term. - */ - @GET - @Path("/search") - def searchWorkflows( - @Auth sessionUser: SessionUser, - @QueryParam("query") keywords: java.util.List[String], - @QueryParam("createDateStart") @DefaultValue("") creationStartDate: String = "", - @QueryParam("createDateEnd") @DefaultValue("") creationEndDate: String = "", - @QueryParam("modifiedDateStart") @DefaultValue("") modifiedStartDate: String = "", - @QueryParam("modifiedDateEnd") @DefaultValue("") modifiedEndDate: String = "", - @QueryParam("owner") owners: java.util.List[String] = new java.util.ArrayList[String](), - @QueryParam("id") workflowIDs: java.util.List[UInteger] = new java.util.ArrayList[UInteger](), - @QueryParam("operator") operators: java.util.List[String] = new java.util.ArrayList[String](), - @QueryParam("projectId") projectIds: java.util.List[UInteger] = - new java.util.ArrayList[UInteger]() - ): List[DashboardWorkflow] = { - val user = sessionUser.getUser - - // make sure keywords don't contain "+-()<>~*\"", these are reserved for SQL full-text boolean operator - val splitKeywords = keywords.asScala.flatMap(word => word.split("[+\\-()<>~*@\"]+")) - var matchQuery: Condition = noCondition() - for (key: String <- splitKeywords) { - if (key != "") { - val words = key.split("\\s+") - - def getSearchQuery(subStringSearchEnabled: Boolean): String = - "(MATCH(texera_db.workflow.name, texera_db.workflow.description, texera_db.workflow.content) AGAINST(+{0}" + - (if (subStringSearchEnabled) "'*'" else "") + " IN BOOLEAN mode) OR " + - "MATCH(texera_db.user.name) AGAINST (+{0}" + - (if (subStringSearchEnabled) "'*'" else "") + " IN BOOLEAN mode) " + - "OR MATCH(texera_db.project.name, texera_db.project.description) AGAINST (+{0}" + - (if (subStringSearchEnabled) "'*'" else "") + " IN BOOLEAN mode))" - - if (words.length == 1) { - // Use "*" to enable sub-string search. - matchQuery = matchQuery.and(getSearchQuery(true), key) - } else { - // When the search query contains multiple words, sub-string search is not supported by MySQL. - matchQuery = matchQuery.and(getSearchQuery(false), "\"" + key + "\"") - } - } - } - - // combine all filters with AND - val optionalFilters: Condition = createWorkflowFilterCondition( - creationStartDate, - creationEndDate, - modifiedStartDate, - modifiedEndDate, - workflowIDs, - owners, - operators, - projectIds - ) - - try { - val workflowEntries = context - .select( - WORKFLOW.WID, - WORKFLOW.NAME, - WORKFLOW.DESCRIPTION, - WORKFLOW.CREATION_TIME, - WORKFLOW.LAST_MODIFIED_TIME, - WORKFLOW_USER_ACCESS.PRIVILEGE, - WORKFLOW_OF_USER.UID, - USER.NAME, - groupConcatDistinct(PROJECT.PID).as("projects") - ) - .from(WORKFLOW) - .leftJoin(WORKFLOW_USER_ACCESS) - .on(WORKFLOW_USER_ACCESS.WID.eq(WORKFLOW.WID)) - .leftJoin(WORKFLOW_OF_USER) - .on(WORKFLOW_OF_USER.WID.eq(WORKFLOW.WID)) - .join(USER) - .on(USER.UID.eq(WORKFLOW_OF_USER.UID)) - .leftJoin(WORKFLOW_OF_PROJECT) - .on(WORKFLOW.WID.eq(WORKFLOW_OF_PROJECT.WID)) - .leftJoin(PROJECT) - .on(PROJECT.PID.eq(WORKFLOW_OF_PROJECT.PID)) - .where(matchQuery) - .and(optionalFilters) - .and(WORKFLOW_USER_ACCESS.UID.eq(user.getUid)) - .groupBy( - WORKFLOW.WID, - WORKFLOW.NAME, - WORKFLOW.DESCRIPTION, - WORKFLOW.CREATION_TIME, - WORKFLOW.LAST_MODIFIED_TIME, - WORKFLOW_USER_ACCESS.PRIVILEGE, - WORKFLOW_OF_USER.UID, - USER.NAME - ) - .fetch() - - workflowEntries - .map(workflowRecord => - DashboardWorkflow( - workflowRecord.into(WORKFLOW_OF_USER).getUid.eq(user.getUid), - workflowRecord - .into(WORKFLOW_USER_ACCESS) - .into(classOf[WorkflowUserAccess]) - .getPrivilege - .toString, - workflowRecord.into(USER).getName, - workflowRecord.into(WORKFLOW).into(classOf[Workflow]), - if (workflowRecord.component9() == null) List[UInteger]() - else - workflowRecord.component9().split(',').map(number => UInteger.valueOf(number)).toList - ) - ) - .asScala - .toList - - } catch { - case e: Exception => - logger.warn( - "Exception: Fulltext index is missing, have you run the script at core/scripts/sql/update/fulltext_indexes.sql?", - e - ) - // return a empty list - List[DashboardWorkflow]() - } - } - } diff --git a/core/amber/src/test/scala/edu/uci/ics/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala b/core/amber/src/test/scala/edu/uci/ics/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala index 5b7d5d08bbb..d014a224ff0 100644 --- a/core/amber/src/test/scala/edu/uci/ics/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala +++ b/core/amber/src/test/scala/edu/uci/ics/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala @@ -16,7 +16,8 @@ import edu.uci.ics.texera.web.resource.dashboard.user.workflow.WorkflowResource. import org.jooq.Condition import org.jooq.impl.DSL.noCondition import edu.uci.ics.texera.web.model.jooq.generated.Tables.{USER, WORKFLOW, WORKFLOW_OF_PROJECT} -import edu.uci.ics.texera.web.resource.dashboard.DashboardResource +import edu.uci.ics.texera.web.resource.dashboard.{DashboardResource, FulltextSearchQueryUtils} +import edu.uci.ics.texera.web.resource.dashboard.DashboardResource.SearchQueryParams import edu.uci.ics.texera.web.resource.dashboard.user.file.UserFileResource import edu.uci.ics.texera.web.resource.dashboard.user.project.ProjectResource @@ -25,7 +26,6 @@ import java.sql.Timestamp import java.text.{ParseException, SimpleDateFormat} import java.util import java.util.Collections -import javax.ws.rs.BadRequestException class WorkflowResourceSpec extends AnyFlatSpec @@ -196,16 +196,16 @@ class WorkflowResourceSpec workflowResource.persistWorkflow(testWorkflow1, sessionUser1) workflowResource.persistWorkflow(testWorkflow3, sessionUser1) // search - var DashboardWorkflowEntryList = - workflowResource.searchWorkflows(sessionUser1, getKeywordsArray(keywordInWorkflow1Content)) - assert(DashboardWorkflowEntryList.head.ownerName.equals(testUser.getName)) - assert(DashboardWorkflowEntryList.length == 1) - assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList.head) - DashboardWorkflowEntryList = - workflowResource.searchWorkflows(sessionUser1, getKeywordsArray(keywordInWorkflow1Content)) - assert(DashboardWorkflowEntryList.head.ownerName.equals(testUser.getName)) + val DashboardWorkflowEntryList = + dashboardResource + .searchAllResourcesCall( + sessionUser1, + SearchQueryParams(keywords = getKeywordsArray(keywordInWorkflow1Content)) + ) + .results + assert(DashboardWorkflowEntryList.head.workflow.get.ownerName.equals(testUser.getName)) assert(DashboardWorkflowEntryList.length == 1) - assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList.head) + assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList.head.workflow.get) } it should "be able to search text phrases" in { @@ -214,11 +214,21 @@ class WorkflowResourceSpec workflowResource.persistWorkflow(testWorkflow1, sessionUser1) workflowResource.persistWorkflow(testWorkflow3, sessionUser1) val DashboardWorkflowEntryList = - workflowResource.searchWorkflows(sessionUser1, getKeywordsArray(keywordInWorkflow1Content)) + dashboardResource + .searchAllResourcesCall( + sessionUser1, + SearchQueryParams(keywords = getKeywordsArray(keywordInWorkflow1Content)) + ) + .results assert(DashboardWorkflowEntryList.length == 1) - assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList.head) + assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList.head.workflow.get) val DashboardWorkflowEntryList1 = - workflowResource.searchWorkflows(sessionUser1, getKeywordsArray("text sear")) + dashboardResource + .searchAllResourcesCall( + sessionUser1, + SearchQueryParams(keywords = getKeywordsArray("text sear")) + ) + .results assert(DashboardWorkflowEntryList1.isEmpty) } @@ -226,10 +236,9 @@ class WorkflowResourceSpec // search "" should return all workflows workflowResource.persistWorkflow(testWorkflow1, sessionUser1) workflowResource.persistWorkflow(testWorkflow3, sessionUser1) - // search with empty keywords - val keywords = new util.ArrayList[String]() - val DashboardWorkflowEntryList = workflowResource.searchWorkflows(sessionUser1, keywords) - assert(DashboardWorkflowEntryList.length == 2) + val DashboardWorkflowEntryList = + dashboardResource.searchAllResourcesCall(sessionUser1, SearchQueryParams()) + assert(DashboardWorkflowEntryList.results.length == 2) } it should "be able to search with arbitrary number of keywords in different combinations" in { @@ -241,23 +250,29 @@ class WorkflowResourceSpec val keywords = new util.ArrayList[String]() keywords.add(keywordInWorkflow1Content) keywords.add(testWorkflow1.getDescription) - val DashboardWorkflowEntryList = workflowResource.searchWorkflows(sessionUser1, keywords) + val DashboardWorkflowEntryList = dashboardResource + .searchAllResourcesCall(sessionUser1, SearchQueryParams(keywords = keywords)) + .results assert(DashboardWorkflowEntryList.size == 1) - assert(DashboardWorkflowEntryList.head.ownerName.equals(testUser.getName)) - assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList.head) + assert(DashboardWorkflowEntryList.head.workflow.get.ownerName.equals(testUser.getName)) + assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList.head.workflow.get) keywords.add("nonexistent") - val DashboardWorkflowEntryList2 = workflowResource.searchWorkflows(sessionUser1, keywords) + val DashboardWorkflowEntryList2 = dashboardResource + .searchAllResourcesCall(sessionUser1, SearchQueryParams(keywords = keywords)) + .results assert(DashboardWorkflowEntryList2.isEmpty) val keywordsReverseOrder = new util.ArrayList[String]() keywordsReverseOrder.add(testWorkflow1.getDescription) keywordsReverseOrder.add(keywordInWorkflow1Content) val DashboardWorkflowEntryList1 = - workflowResource.searchWorkflows(sessionUser1, keywordsReverseOrder) + dashboardResource + .searchAllResourcesCall(sessionUser1, SearchQueryParams(keywords = keywordsReverseOrder)) + .results assert(DashboardWorkflowEntryList1.size == 1) - assert(DashboardWorkflowEntryList1.head.ownerName.equals(testUser.getName)) - assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList1.head) + assert(DashboardWorkflowEntryList1.head.workflow.get.ownerName.equals(testUser.getName)) + assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList1.head.workflow.get) } @@ -266,38 +281,23 @@ class WorkflowResourceSpec // search "key+-pair" or "key@pair" or "key+" or "+key" should return testWorkflow1 workflowResource.persistWorkflow(testWorkflow1, sessionUser1) workflowResource.persistWorkflow(testWorkflow3, sessionUser1) - // search with reserved characters in keywords - var DashboardWorkflowEntryList = workflowResource.searchWorkflows( - sessionUser1, - getKeywordsArray(keywordInWorkflow1Content + "+-@()<>~*\"" + keywordInWorkflow1Content) - ) - assert(DashboardWorkflowEntryList.size == 1) - assert(DashboardWorkflowEntryList.head.ownerName.equals(testUser.getName)) - assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList.head) - DashboardWorkflowEntryList = workflowResource.searchWorkflows( - sessionUser1, - getKeywordsArray(keywordInWorkflow1Content + "@" + keywordInWorkflow1Content) - ) - assert(DashboardWorkflowEntryList.size == 1) - assert(DashboardWorkflowEntryList.head.ownerName.equals(testUser.getName)) - assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList.head) - - DashboardWorkflowEntryList = workflowResource.searchWorkflows( - sessionUser1, - getKeywordsArray(keywordInWorkflow1Content + "+-@()<>~*\"") - ) - assert(DashboardWorkflowEntryList.size == 1) - assert(DashboardWorkflowEntryList.head.ownerName.equals(testUser.getName)) - assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList.head) + def testInner(keywords: String): Unit = { + val DashboardWorkflowEntryList = dashboardResource + .searchAllResourcesCall( + sessionUser1, + SearchQueryParams(keywords = getKeywordsArray(keywords)) + ) + .results + assert(DashboardWorkflowEntryList.size == 1) + assert(DashboardWorkflowEntryList.head.workflow.get.ownerName.equals(testUser.getName)) + assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList.head.workflow.get) + } - DashboardWorkflowEntryList = workflowResource.searchWorkflows( - sessionUser1, - getKeywordsArray("+-@()<>~*\"" + keywordInWorkflow1Content) - ) - assert(DashboardWorkflowEntryList.size == 1) - assert(DashboardWorkflowEntryList.head.ownerName.equals(testUser.getName)) - assertSameWorkflow(testWorkflow1, DashboardWorkflowEntryList.head) + testInner(keywordInWorkflow1Content + "+-@()<>~*\"" + keywordInWorkflow1Content) + testInner(keywordInWorkflow1Content + "@" + keywordInWorkflow1Content) + testInner(keywordInWorkflow1Content + "+-@()<>~*\"") + testInner("+-@()<>~*\"" + keywordInWorkflow1Content) } @@ -307,7 +307,9 @@ class WorkflowResourceSpec workflowResource.persistWorkflow(testWorkflow3, sessionUser1) val DashboardWorkflowEntryList = - workflowResource.searchWorkflows(sessionUser1, getKeywordsArray("+-@()<>~*\"")) + dashboardResource + .searchAllResourcesCall(sessionUser1, SearchQueryParams(getKeywordsArray("+-@()<>~*\""))) + .results assert(DashboardWorkflowEntryList.size == 2) } @@ -323,10 +325,15 @@ class WorkflowResourceSpec def test(user: SessionUser, workflow: Workflow): Unit = { // search with reserved characters in keywords val DashboardWorkflowEntryList = - workflowResource.searchWorkflows(user, getKeywordsArray(workflow.getDescription)) + dashboardResource + .searchAllResourcesCall( + user, + SearchQueryParams(getKeywordsArray(workflow.getDescription)) + ) + .results assert(DashboardWorkflowEntryList.size == 1) - assert(DashboardWorkflowEntryList.head.ownerName.equals(user.getName())) - assertSameWorkflow(workflow, DashboardWorkflowEntryList.head) + assert(DashboardWorkflowEntryList.head.workflow.get.ownerName.equals(user.getName())) + assertSameWorkflow(workflow, DashboardWorkflowEntryList.head.workflow.get) } test(sessionUser1, testWorkflow1) test(sessionUser2, testWorkflow2) @@ -338,81 +345,37 @@ class WorkflowResourceSpec workflowResource.persistWorkflow(testWorkflowWithSpecialCharacters, sessionUser1) workflowResource.persistWorkflow(testWorkflow3, sessionUser1) val DashboardWorkflowEntryList = - workflowResource.searchWorkflows(sessionUser1, getKeywordsArray(exampleEmailAddress)) + dashboardResource + .searchAllResourcesCall( + sessionUser1, + SearchQueryParams(getKeywordsArray(exampleEmailAddress)) + ) + .results assert(DashboardWorkflowEntryList.size == 1) - assertSameWorkflow(testWorkflowWithSpecialCharacters, DashboardWorkflowEntryList.head) - } - - it should "be case insensitive" in { - // testWorkflow1: {name: test_name, description: test_description, content: "key pair"} - // search ["key", "pair] or ["KEY", "PAIR"] should return the same result - workflowResource.persistWorkflow(testWorkflowWithSpecialCharacters, sessionUser1) - workflowResource.persistWorkflow(testWorkflow3, sessionUser1) - val keywords = new util.ArrayList[String]() - keywords.add(exampleWord1.toLowerCase()) - keywords.add(exampleWord2.toUpperCase()) - val DashboardWorkflowEntryList = workflowResource.searchWorkflows(sessionUser1, keywords) - assert(DashboardWorkflowEntryList.size == 1) - assertSameWorkflow(testWorkflowWithSpecialCharacters, DashboardWorkflowEntryList.head) - } - - it should "be order insensitive" in { - // testWorkflow1: {name: test_name, description: test_description, content: "key pair"} - // search ["key", "pair] or ["pair", "key"] should return the same result - workflowResource.persistWorkflow(testWorkflowWithSpecialCharacters, sessionUser1) - workflowResource.persistWorkflow(testWorkflow3, sessionUser1) - val keywords = new util.ArrayList[String]() - keywords.add(exampleWord2) - keywords.add(exampleWord1) - val DashboardWorkflowEntryList = workflowResource.searchWorkflows(sessionUser1, keywords) - assert(DashboardWorkflowEntryList.size == 1) - assertSameWorkflow(testWorkflowWithSpecialCharacters, DashboardWorkflowEntryList.head) - } - - "getOwnerFilter" should "return a noCondition when the input owner list is null" in { - val ownerFilter: Condition = WorkflowResource.getOwnerFilter(null) - assert(ownerFilter.toString == noCondition().toString) - } - - it should "return a noCondition when the input owner list is empty" in { - val ownerFilter: Condition = WorkflowResource.getOwnerFilter(Collections.emptyList[String]()) - assert(ownerFilter.toString == noCondition().toString) + assertSameWorkflow( + testWorkflowWithSpecialCharacters, + DashboardWorkflowEntryList.head.workflow.get + ) } it should "return a proper condition for a single owner" in { val ownerList = new java.util.ArrayList[String](util.Arrays.asList("owner1")) - val ownerFilter: Condition = WorkflowResource.getOwnerFilter(ownerList) + val ownerFilter: Condition = + FulltextSearchQueryUtils.getContainsFilter(ownerList, USER.EMAIL) assert(ownerFilter.toString == USER.EMAIL.eq("owner1").toString) } it should "return a proper condition for multiple owners" in { val ownerList = new java.util.ArrayList[String](util.Arrays.asList("owner1", "owner2")) - val ownerFilter: Condition = WorkflowResource.getOwnerFilter(ownerList) - assert(ownerFilter.toString == USER.EMAIL.eq("owner1").or(USER.EMAIL.eq("owner2")).toString) - } - - it should "return a proper condition for multiple owners with duplicates" in { - val ownerList = - new java.util.ArrayList[String](util.Arrays.asList("owner1", "owner2", "owner2")) - val ownerFilter: Condition = WorkflowResource.getOwnerFilter(ownerList) + val ownerFilter: Condition = + FulltextSearchQueryUtils.getContainsFilter(ownerList, USER.EMAIL) assert(ownerFilter.toString == USER.EMAIL.eq("owner1").or(USER.EMAIL.eq("owner2")).toString) } - "getProjectFilter" should "return a noCondition when the input projectIds list is null" in { - val projectFilter: Condition = WorkflowResource.getProjectFilter(null, WORKFLOW_OF_PROJECT.PID) - assert(projectFilter.toString == noCondition().toString) - } - - it should "return a noCondition when the input projectIds list is empty" in { - val projectFilter: Condition = - WorkflowResource.getProjectFilter(Collections.emptyList[UInteger](), WORKFLOW_OF_PROJECT.PID) - assert(projectFilter.toString == noCondition().toString) - } - it should "return a proper condition for a single projectId" in { val projectIdList = new java.util.ArrayList[UInteger](util.Arrays.asList(UInteger.valueOf(1))) val projectFilter: Condition = - WorkflowResource.getProjectFilter(projectIdList, WORKFLOW_OF_PROJECT.PID) + FulltextSearchQueryUtils.getContainsFilter(projectIdList, WORKFLOW_OF_PROJECT.PID) assert(projectFilter.toString == WORKFLOW_OF_PROJECT.PID.eq(UInteger.valueOf(1)).toString) } @@ -421,7 +384,7 @@ class WorkflowResourceSpec util.Arrays.asList(UInteger.valueOf(1), UInteger.valueOf(2)) ) val projectFilter: Condition = - WorkflowResource.getProjectFilter(projectIdList, WORKFLOW_OF_PROJECT.PID) + FulltextSearchQueryUtils.getContainsFilter(projectIdList, WORKFLOW_OF_PROJECT.PID) assert( projectFilter.toString == WORKFLOW_OF_PROJECT.PID .eq(UInteger.valueOf(1)) @@ -430,34 +393,10 @@ class WorkflowResourceSpec ) } - it should "return a proper condition for multiple projectIds with duplicates" in { - val projectIdList = new java.util.ArrayList[UInteger]( - util.Arrays.asList(UInteger.valueOf(1), UInteger.valueOf(2), UInteger.valueOf(2)) - ) - val projectFilter: Condition = - WorkflowResource.getProjectFilter(projectIdList, WORKFLOW_OF_PROJECT.PID) - assert( - projectFilter.toString == WORKFLOW_OF_PROJECT.PID - .eq(UInteger.valueOf(1)) - .or(WORKFLOW_OF_PROJECT.PID.eq(UInteger.valueOf(2))) - .toString - ) - } - - "getWorkflowIdFilter" should "return a noCondition when the input workflowIDs list is null" in { - val workflowIdFilter: Condition = WorkflowResource.getWorkflowIdFilter(null) - assert(workflowIdFilter.toString == noCondition().toString) - } - - it should "return a noCondition when the input workflowIDs list is empty" in { - val workflowIdFilter: Condition = - WorkflowResource.getWorkflowIdFilter(Collections.emptyList[UInteger]()) - assert(workflowIdFilter.toString == noCondition().toString) - } - it should "return a proper condition for a single workflowID" in { val workflowIdList = new java.util.ArrayList[UInteger](util.Arrays.asList(UInteger.valueOf(1))) - val workflowIdFilter: Condition = WorkflowResource.getWorkflowIdFilter(workflowIdList) + val workflowIdFilter: Condition = + FulltextSearchQueryUtils.getContainsFilter(workflowIdList, WORKFLOW.WID) assert(workflowIdFilter.toString == WORKFLOW.WID.eq(UInteger.valueOf(1)).toString) } @@ -465,20 +404,8 @@ class WorkflowResourceSpec val workflowIdList = new java.util.ArrayList[UInteger]( util.Arrays.asList(UInteger.valueOf(1), UInteger.valueOf(2)) ) - val workflowIdFilter: Condition = WorkflowResource.getWorkflowIdFilter(workflowIdList) - assert( - workflowIdFilter.toString == WORKFLOW.WID - .eq(UInteger.valueOf(1)) - .or(WORKFLOW.WID.eq(UInteger.valueOf(2))) - .toString - ) - } - - it should "return a proper condition for multiple workflowIDs with duplicates" in { - val workflowIdList = new java.util.ArrayList[UInteger]( - util.Arrays.asList(UInteger.valueOf(1), UInteger.valueOf(2), UInteger.valueOf(2)) - ) - val workflowIdFilter: Condition = WorkflowResource.getWorkflowIdFilter(workflowIdList) + val workflowIdFilter: Condition = + FulltextSearchQueryUtils.getContainsFilter(workflowIdList, WORKFLOW.WID) assert( workflowIdFilter.toString == WORKFLOW.WID .eq(UInteger.valueOf(1)) @@ -487,14 +414,13 @@ class WorkflowResourceSpec ) } - "getDateFilter" should "return a noCondition when the input startDate and endDate are empty" in { - val dateFilter: Condition = WorkflowResource.getDateFilter("", "", WORKFLOW.CREATION_TIME) - assert(dateFilter.toString == noCondition().toString) - } - it should "return a proper condition for creation date type with specific start and end date" in { val dateFilter: Condition = - WorkflowResource.getDateFilter("2023-01-01", "2023-12-31", WORKFLOW.CREATION_TIME) + FulltextSearchQueryUtils.getDateFilter( + "2023-01-01", + "2023-12-31", + WORKFLOW.CREATION_TIME + ) val dateFormat = new SimpleDateFormat("yyyy-MM-dd") val startTimestamp = new Timestamp(dateFormat.parse("2023-01-01").getTime) val endTimestamp = @@ -508,7 +434,11 @@ class WorkflowResourceSpec it should "return a proper condition for modification date type with specific start and end date" in { val dateFilter: Condition = - WorkflowResource.getDateFilter("2023-01-01", "2023-12-31", WORKFLOW.LAST_MODIFIED_TIME) + FulltextSearchQueryUtils.getDateFilter( + "2023-01-01", + "2023-12-31", + WORKFLOW.LAST_MODIFIED_TIME + ) val dateFormat = new SimpleDateFormat("yyyy-MM-dd") val startTimestamp = new Timestamp(dateFormat.parse("2023-01-01").getTime) val endTimestamp = @@ -524,19 +454,27 @@ class WorkflowResourceSpec it should "throw a ParseException when endDate is invalid" in { assertThrows[ParseException] { - WorkflowResource.getDateFilter("2023-01-01", "invalidDate", WORKFLOW.CREATION_TIME) + FulltextSearchQueryUtils.getDateFilter( + "2023-01-01", + "invalidDate", + WORKFLOW.CREATION_TIME + ) } } "getOperatorsFilter" should "return a noCondition when the input operators list is empty" in { val operatorsFilter: Condition = - WorkflowResource.getOperatorsFilter(Collections.emptyList[String]()) + FulltextSearchQueryUtils.getOperatorsFilter( + Collections.emptyList[String](), + WORKFLOW.CONTENT + ) assert(operatorsFilter.toString == noCondition().toString) } it should "return a proper condition for a single operator" in { val operatorsList = new java.util.ArrayList[String](util.Arrays.asList("operator1")) - val operatorsFilter: Condition = WorkflowResource.getOperatorsFilter(operatorsList) + val operatorsFilter: Condition = + FulltextSearchQueryUtils.getOperatorsFilter(operatorsList, WORKFLOW.CONTENT) val searchKey = "%\"operatorType\":\"operator1\"%" assert(operatorsFilter.toString == WORKFLOW.CONTENT.likeIgnoreCase(searchKey).toString) } @@ -544,7 +482,8 @@ class WorkflowResourceSpec it should "return a proper condition for multiple operators" in { val operatorsList = new java.util.ArrayList[String](util.Arrays.asList("operator1", "operator2")) - val operatorsFilter: Condition = WorkflowResource.getOperatorsFilter(operatorsList) + val operatorsFilter: Condition = + FulltextSearchQueryUtils.getOperatorsFilter(operatorsList, WORKFLOW.CONTENT) val searchKey1 = "%\"operatorType\":\"operator1\"%" val searchKey2 = "%\"operatorType\":\"operator2\"%" assert( @@ -571,23 +510,22 @@ class WorkflowResourceSpec assert(response.getStatusInfo.getStatusCode == 200) // search val DashboardClickableFileEntryList = - dashboardResource.searchAllResources(sessionUser1, getKeywordsArray("test")) + dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(getKeywordsArray("test")) + ) assert(DashboardClickableFileEntryList.results.length == 3) } - it should "return an empty list when there are no matching resources" in { - val DashboardClickableFileEntryList = - dashboardResource.searchAllResources(sessionUser1, getKeywordsArray("not-existing-keyword")) - assert(DashboardClickableFileEntryList.results.isEmpty) - } - it should "return all resources when no keyword provided" in { - projectResource.createProject(sessionUser1, "test project1") workflowResource.persistWorkflow(testWorkflow1, sessionUser1) val DashboardClickableFileEntryList = - dashboardResource.searchAllResources(sessionUser1, getKeywordsArray("")) + dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(getKeywordsArray("")) + ) assert(DashboardClickableFileEntryList.results.length == 2) } @@ -604,7 +542,10 @@ class WorkflowResourceSpec assert(response.getStatusInfo.getStatusCode == 200) val DashboardClickableFileEntryList = - dashboardResource.searchAllResources(sessionUser1, getKeywordsArray("unique")) + dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(getKeywordsArray("unique")) + ) assert(DashboardClickableFileEntryList.results.length == 1) } @@ -621,7 +562,10 @@ class WorkflowResourceSpec ) assert(response.getStatusInfo.getStatusCode == 200) val DashboardClickableFileEntryList = - dashboardResource.searchAllResources(sessionUser1, getKeywordsArray("common")) + dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(getKeywordsArray("common")) + ) assert(DashboardClickableFileEntryList.results.length == 2) } @@ -638,7 +582,10 @@ class WorkflowResourceSpec assert(response.getStatusInfo.getStatusCode == 200) val DashboardClickableFileEntryList = - dashboardResource.searchAllResources(sessionUser1, getKeywordsArray("test", "project1")) + dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(getKeywordsArray("test", "project1")) + ) assert( DashboardClickableFileEntryList.results.length == 1 ) // should only return the project @@ -668,35 +615,35 @@ class WorkflowResourceSpec assert(response.getStatusInfo.getStatusCode == 200) // search resources with all resourceType var DashboardClickableFileEntryList = - dashboardResource.searchAllResources(sessionUser1, getKeywordsArray("test")) + dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(getKeywordsArray("test")) + ) assert(DashboardClickableFileEntryList.results.length == 6) // filter resources by workflow - DashboardClickableFileEntryList = - dashboardResource.searchAllResources(sessionUser1, getKeywordsArray("test"), "workflow") + DashboardClickableFileEntryList = dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(resourceType = "workflow", keywords = getKeywordsArray("test")) + ) assert(DashboardClickableFileEntryList.results.length == 1) // filter resources by project - DashboardClickableFileEntryList = - dashboardResource.searchAllResources(sessionUser1, getKeywordsArray("test"), "project") + DashboardClickableFileEntryList = dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(resourceType = "project", keywords = getKeywordsArray("test")) + ) assert(DashboardClickableFileEntryList.results.length == 3) // filter resources by file - DashboardClickableFileEntryList = - dashboardResource.searchAllResources(sessionUser1, getKeywordsArray("test"), "file") + DashboardClickableFileEntryList = dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(resourceType = "file", keywords = getKeywordsArray("test")) + ) assert(DashboardClickableFileEntryList.results.length == 2) } - it should "throw an BadRequestException for invalid resourceType" in { - assertThrows[BadRequestException] { - dashboardResource.searchAllResources( - sessionUser1, - getKeywordsArray("test"), - "invalid-resource-type" - ) - } - } it should "return resources that match any of all provided keywords" in { // This test is designed to verify that the searchAllResources function correctly // returns resources that match all of the provided keywords @@ -715,7 +662,10 @@ class WorkflowResourceSpec // Perform search with multiple keywords val DashboardClickableFileEntryList = - dashboardResource.searchAllResources(sessionUser1, getKeywordsArray("test", "project")) + dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(keywords = getKeywordsArray("test", "project")) + ) // Assert that the search results include resources that match any of the provided keywords assert(DashboardClickableFileEntryList.results.length == 1) @@ -729,45 +679,16 @@ class WorkflowResourceSpec // Perform search for resources using sessionUser1 val DashboardClickableFileEntryList = - dashboardResource.searchAllResources(sessionUser1, getKeywordsArray("test")) + dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(keywords = getKeywordsArray("test")) + ) // Assert that the search results do not include the project that belongs to the different user // Assuming that DashboardClickableFileEntryList is a list of resources where each resource has a `user` property assert(DashboardClickableFileEntryList.results.isEmpty) } - it should "handle reserved characters in the keywords in searchAllResources" in { - // testWorkflow1: {name: test_name, description: test_description, content: "key pair"} - // search "key+-pair" or "key@pair" or "key+" or "+key" should return testWorkflow1 - workflowResource.persistWorkflow(testWorkflow1, sessionUser1) - - // search with reserved characters in keywords - var DashboardClickableFileEntryList = dashboardResource.searchAllResources( - sessionUser1, - getKeywordsArray(keywordInWorkflow1Content + "+-@()<>~*\"" + keywordInWorkflow1Content) - ) - assert(DashboardClickableFileEntryList.results.length == 1) - - DashboardClickableFileEntryList = dashboardResource.searchAllResources( - sessionUser1, - getKeywordsArray(keywordInWorkflow1Content + "@" + keywordInWorkflow1Content) - ) - assert(DashboardClickableFileEntryList.results.size == 1) - - DashboardClickableFileEntryList = dashboardResource.searchAllResources( - sessionUser1, - getKeywordsArray(keywordInWorkflow1Content + "+-@()<>~*\"") - ) - assert(DashboardClickableFileEntryList.results.size == 1) - - DashboardClickableFileEntryList = dashboardResource.searchAllResources( - sessionUser1, - getKeywordsArray("+-@()<>~*\"" + keywordInWorkflow1Content) - ) - assert(DashboardClickableFileEntryList.results.size == 1) - - } - it should "paginate results correctly" in { // This test is designed to verify that the pagination works correctly @@ -782,21 +703,30 @@ class WorkflowResourceSpec } // Request the first page of results (page size is 10) - val firstPage = dashboardResource.searchAllResources(sessionUser1, count = 10) + val firstPage = + dashboardResource.searchAllResourcesCall(sessionUser1, SearchQueryParams(count = 10)) // Assert that the first page has 10 results assert(firstPage.results.length == 10) assert(firstPage.more) // Assert that there are more results to be fetched // Request the second page of results - val secondPage = dashboardResource.searchAllResources(sessionUser1, count = 10, offset = 10) + val secondPage = + dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(count = 10, offset = 10) + ) // Assert that the second page has 10 results assert(secondPage.results.length == 10) assert(secondPage.more) // Assert that there are more results to be fetched // Request the third page of results - val thirdPage = dashboardResource.searchAllResources(sessionUser1, count = 10, offset = 20) + val thirdPage = + dashboardResource.searchAllResourcesCall( + sessionUser1, + SearchQueryParams(count = 10, offset = 20) + ) // Assert that the third page has 5 results (since we only have 25 resources) assert(thirdPage.results.length == 1) @@ -815,192 +745,24 @@ class WorkflowResourceSpec // Retrieve resources ordered by name in ascending order var resources = - dashboardResource.searchAllResources( + dashboardResource.searchAllResourcesCall( sessionUser1, - resourceType = "workflow", - orderBy = "NameAsc" + SearchQueryParams(resourceType = "workflow", orderBy = "NameAsc") ) // Check the order of the results - assert(resources.results(0).workflow.workflow.getName == "test_workflow1") - assert(resources.results(1).workflow.workflow.getName == "test_workflow2") - assert(resources.results(2).workflow.workflow.getName == "test_workflow3") + assert(resources.results(0).workflow.get.workflow.getName == "test_workflow1") + assert(resources.results(1).workflow.get.workflow.getName == "test_workflow2") + assert(resources.results(2).workflow.get.workflow.getName == "test_workflow3") - resources = dashboardResource.searchAllResources( + resources = dashboardResource.searchAllResourcesCall( sessionUser1, - resourceType = "workflow", - orderBy = "NameDesc" + SearchQueryParams(resourceType = "workflow", orderBy = "NameDesc") ) // Check the order of the results - assert(resources.results(0).workflow.workflow.getName == "test_workflow3") - assert(resources.results(1).workflow.workflow.getName == "test_workflow2") - assert(resources.results(2).workflow.workflow.getName == "test_workflow1") - } - - it should "order workflow by creation time in descending order correctly" in { - // Create several resources with different creation times - workflowResource.persistWorkflow(testWorkflow1, sessionUser1) - Thread.sleep(1000) - workflowResource.persistWorkflow(testWorkflow2, sessionUser1) - Thread.sleep(1000) - workflowResource.persistWorkflow(testWorkflow3, sessionUser1) - - // Retrieve resources ordered by creation time in descending order - var resources = - dashboardResource.searchAllResources( - sessionUser1, - resourceType = "workflow", - orderBy = "CreateTimeDesc" - ) - - // Check the order of the results - assert(resources.results(0).workflow.workflow.getName == "test_workflow3") - assert(resources.results(1).workflow.workflow.getName == "test_workflow2") - assert(resources.results(2).workflow.workflow.getName == "test_workflow1") - } - - it should "order project by name in ascending order correctly" in { - // Create several resources with different names - projectResource.createProject(sessionUser1, "test project C") - projectResource.createProject(sessionUser1, "test project A") - projectResource.createProject(sessionUser1, "test project B") - - // Retrieve resources ordered by name in ascending order - val resources = - dashboardResource.searchAllResources( - sessionUser1, - resourceType = "project", - orderBy = "NameAsc" - ) - - // Check the order of the results - assert(resources.results(0).project.getName == "test project A") - assert(resources.results(1).project.getName == "test project B") - assert(resources.results(2).project.getName == "test project C") - } - - it should "order project by name in descending order correctly" in { - // Create several resources with different names - projectResource.createProject(sessionUser1, "test project C") - projectResource.createProject(sessionUser1, "test project A") - projectResource.createProject(sessionUser1, "test project B") - - // Retrieve resources ordered by name in descending order - val resources = - dashboardResource.searchAllResources( - sessionUser1, - resourceType = "project", - orderBy = "NameDesc" - ) - - // Check the order of the results - assert(resources.results(0).project.getName == "test project C") - assert(resources.results(1).project.getName == "test project B") - assert(resources.results(2).project.getName == "test project A") - } - - it should "order project by creation time in descending order correctly" in { - // Create several resources with different creation times - projectResource.createProject(sessionUser1, "test project A") - Thread.sleep(1000) - projectResource.createProject(sessionUser1, "test project B") - Thread.sleep(1000) - projectResource.createProject(sessionUser1, "test project C") - - // Retrieve resources ordered by creation time in descending order - val resources = - dashboardResource.searchAllResources( - sessionUser1, - resourceType = "project", - orderBy = "CreateTimeDesc" - ) - - // Check the order of the results - assert(resources.results(0).project.getName == "test project C") - assert(resources.results(1).project.getName == "test project B") - assert(resources.results(2).project.getName == "test project A") - } - - it should "throw a BadRequestException when given an unknown orderBy value" in { - // Attempt to retrieve resources with an invalid orderBy value - assertThrows[BadRequestException] { - dashboardResource.searchAllResources(sessionUser1, orderBy = "InvalidOrderBy") - } - } - - it should "order file by name in ascending order correctly" in { - // Create several resources with different names - val inA = org.apache.commons.io.IOUtils.toInputStream("", "UTF-8") - fileResource.uploadFile(inA, "test file A", sessionUser1) - Thread.sleep(1000) - val inB = org.apache.commons.io.IOUtils.toInputStream("", "UTF-8") - fileResource.uploadFile(inB, "test file B", sessionUser1) - Thread.sleep(1000) - val inC = org.apache.commons.io.IOUtils.toInputStream("", "UTF-8") - fileResource.uploadFile(inC, "test file C", sessionUser1) - - // Retrieve resources ordered by name in ascending order - var resources = - dashboardResource.searchAllResources(sessionUser1, resourceType = "file", orderBy = "NameAsc") - - // Check the order of the results - assert(resources.results(0).file.file.getName == "test file A") - assert(resources.results(1).file.file.getName == "test file B") - assert(resources.results(2).file.file.getName == "test file C") - - // Retrieve resources ordered by name in descending order - resources = dashboardResource.searchAllResources( - sessionUser1, - resourceType = "file", - orderBy = "NameDesc" - ) - // Check the order of the results - assert(resources.results(2).file.file.getName == "test file A") - assert(resources.results(1).file.file.getName == "test file B") - assert(resources.results(0).file.file.getName == "test file C") - } - - it should "order file by creation time in descending order correctly" in { - // Create several resources with different names - val inA = org.apache.commons.io.IOUtils.toInputStream("", "UTF-8") - fileResource.uploadFile(inA, "test file B", sessionUser1) - Thread.sleep(1000) - val inB = org.apache.commons.io.IOUtils.toInputStream("", "UTF-8") - fileResource.uploadFile(inB, "test file A", sessionUser1) - Thread.sleep(1000) - val inC = org.apache.commons.io.IOUtils.toInputStream("", "UTF-8") - fileResource.uploadFile(inC, "test file C", sessionUser1) - - // Retrieve resources ordered by creation time in descending order - val resources = - dashboardResource.searchAllResources( - sessionUser1, - resourceType = "file", - orderBy = "CreateTimeDesc" - ) - - assert(resources.results(0).file.file.getName == "test file C") - assert(resources.results(1).file.file.getName == "test file A") - assert(resources.results(2).file.file.getName == "test file B") - } - - it should "order all resource types by creation_time in descending order correctly" in { - // Create resources - val inA = org.apache.commons.io.IOUtils.toInputStream("", "UTF-8") - fileResource.uploadFile(inA, "test file C", sessionUser1) - Thread.sleep(1000) - projectResource.createProject(sessionUser1, "test project B") - Thread.sleep(1000) - workflowResource.persistWorkflow(testWorkflow1, sessionUser1) - Thread.sleep(1000) - - // Retrieve resources ordered by name in descending order - val resources = dashboardResource.searchAllResources(sessionUser1, orderBy = "CreateTimeDesc") - assert(resources.results.length == 3) - // Check the order of the results - assert(resources.results(2).file.file.getName == "test file C") - assert(resources.results(1).project.getName == "test project B") - assert(resources.results(0).workflow.workflow.getName == "test_workflow1") + assert(resources.results(0).workflow.get.workflow.getName == "test_workflow3") + assert(resources.results(1).workflow.get.workflow.getName == "test_workflow2") + assert(resources.results(2).workflow.get.workflow.getName == "test_workflow1") } }