From 70bc136d1556f1dffa4a64c195613210d9dd2650 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 6 Jun 2024 17:26:01 +0800 Subject: [PATCH] batch get labels from highlight id --- packages/api/src/apollo.ts | 6 +- .../api/src/resolvers/function_resolvers.ts | 9 + packages/api/src/resolvers/types.ts | 1 + packages/api/src/services/highlights.ts | 181 ++---------------- packages/api/src/services/labels.ts | 17 ++ packages/api/test/resolvers/highlight.test.ts | 2 +- 6 files changed, 51 insertions(+), 165 deletions(-) diff --git a/packages/api/src/apollo.ts b/packages/api/src/apollo.ts index b06bcd304..e51a6aef2 100644 --- a/packages/api/src/apollo.ts +++ b/packages/api/src/apollo.ts @@ -33,7 +33,10 @@ import ScalarResolvers from './scalars' import typeDefs from './schema' import { batchGetHighlightsFromLibraryItemIds } from './services/highlights' import { batchGetPublicItems } from './services/home' -import { batchGetLabelsFromLibraryItemIds } from './services/labels' +import { + batchGetLabelsFromHighlightIds, + batchGetLabelsFromLibraryItemIds, +} from './services/labels' import { batchGetLibraryItems } from './services/library_item' import { batchGetRecommendationsFromLibraryItemIds } from './services/recommendation' import { @@ -128,6 +131,7 @@ const contextFunc: ContextFunction = async ({ users: new DataLoader(async (ids: readonly string[]) => findUsersByIds(ids as string[]) ), + highlightLabels: new DataLoader(batchGetLabelsFromHighlightIds), }, } diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index aed2b51e8..6ed9de486 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -475,6 +475,15 @@ export const functionResolvers = { return ctx.dataLoaders.libraryItems.load(highlight.libraryItemId) }, + labels: async ( + highlight: Highlight, + _: unknown, + ctx: WithDataSourcesContext + ) => { + return ( + highlight.labels || ctx.dataLoaders.highlightLabels.load(highlight.id) + ) + }, }, SearchItem: { async url(item: LibraryItem, _: unknown, ctx: WithDataSourcesContext) { diff --git a/packages/api/src/resolvers/types.ts b/packages/api/src/resolvers/types.ts index cc833e556..16857d85d 100644 --- a/packages/api/src/resolvers/types.ts +++ b/packages/api/src/resolvers/types.ts @@ -60,6 +60,7 @@ export interface RequestContext { publicItems: DataLoader subscriptions: DataLoader users: DataLoader + highlightLabels: DataLoader } } diff --git a/packages/api/src/services/highlights.ts b/packages/api/src/services/highlights.ts index e154faf71..80a011589 100644 --- a/packages/api/src/services/highlights.ts +++ b/packages/api/src/services/highlights.ts @@ -1,18 +1,16 @@ -import { ExpressionToken, LiqeQuery } from '@omnivore/liqe' import { diff_match_patch } from 'diff-match-patch' -import { DeepPartial, In, ObjectLiteral } from 'typeorm' +import { DeepPartial, In } from 'typeorm' import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity' import { EntityLabel } from '../entity/entity_label' import { Highlight } from '../entity/highlight' import { Label } from '../entity/label' import { homePageURL } from '../env' import { createPubSubClient, EntityEvent, EntityType } from '../pubsub' -import { authTrx, paramtersToObject } from '../repository' +import { authTrx } from '../repository' import { highlightRepository } from '../repository/highlight' import { Merge } from '../util' import { enqueueUpdateHighlight } from '../utils/createTask' import { deepDelete } from '../utils/helpers' -import { parseSearchQuery } from '../utils/search' import { ItemEvent } from './library_item' const columnsToDelete = ['user', 'sharedAt', 'libraryItem'] as const @@ -281,149 +279,6 @@ export const findHighlightsByLibraryItemId = async ( ) } -export const buildQueryString = ( - searchQuery: LiqeQuery, - parameters: ObjectLiteral[] = [] -) => { - const escapeQueryWithParameters = ( - query: string, - parameter: ObjectLiteral - ) => { - parameters.push(parameter) - return query - } - - const serializeImplicitField = ( - expression: ExpressionToken - ): string | null => { - if (expression.type !== 'LiteralExpression') { - throw new Error('Expected a literal expression') - } - - // not implemented yet - return null - } - - const serializeTagExpression = (ast: LiqeQuery): string | null => { - if (ast.type !== 'Tag') { - throw new Error('Expected a tag expression') - } - - const { field, expression } = ast - - if (field.type === 'ImplicitField') { - return serializeImplicitField(expression) - } else { - if (expression.type !== 'LiteralExpression') { - // ignore empty values - return null - } - - const value = expression.value?.toString() - if (!value) { - // ignore empty values - return null - } - - switch (field.name.toLowerCase()) { - case 'label': { - const labels = value.toLowerCase().split(',') - return ( - labels - .map((label) => { - const param = `label_${parameters.length}` - - const hasWildcard = label.includes('*') - if (hasWildcard) { - return escapeQueryWithParameters( - `label.name ILIKE :${param}`, - { - [param]: label.replace(/\*/g, '%'), - } - ) - } - - return escapeQueryWithParameters( - `LOWER(label.name) = :${param}`, - { - [param]: label.toLowerCase(), - } - ) - }) - .join(' OR ') - // wrap in brackets to avoid precedence issues - .replace(/^(.*)$/, '($1)') - ) - } - default: - // treat unknown fields as implicit fields - return serializeImplicitField({ - ...expression, - value: `${field.name}:${value}`, - }) - } - } - } - - const serialize = (ast: LiqeQuery): string | null => { - if (ast.type === 'Tag') { - return serializeTagExpression(ast) - } - - if (ast.type === 'LogicalExpression') { - let operator = '' - if (ast.operator.operator === 'AND') { - operator = 'AND' - } else if (ast.operator.operator === 'OR') { - operator = 'OR' - } else { - throw new Error('Unexpected operator') - } - - const left = serialize(ast.left) - const right = serialize(ast.right) - - if (!left && !right) { - return null - } - - if (!left) { - return right - } - - if (!right) { - return left - } - - return `${left} ${operator} ${right}` - } - - if (ast.type === 'UnaryOperator') { - const serialized = serialize(ast.operand) - - if (!serialized) { - return null - } - - return `NOT ${serialized}` - } - - if (ast.type === 'ParenthesizedExpression') { - const serialized = serialize(ast.expression) - - if (!serialized) { - return null - } - - return `(${serialized})` - } - - return null - } - - return serialize(searchQuery) -} - export const searchHighlights = async ( userId: string, query?: string, @@ -432,32 +287,32 @@ export const searchHighlights = async ( ): Promise> => { return authTrx( async (tx) => { - // TODO: parse query and search by it const queryBuilder = tx .getRepository(Highlight) .createQueryBuilder('highlight') - - queryBuilder .andWhere('highlight.userId = :userId', { userId }) .orderBy('highlight.updatedAt', 'DESC') .take(limit) .skip(offset) if (query) { - const parameters: ObjectLiteral[] = [] + // parse query and search by it + const labelRegex = /label:"([^"]+)"/g + const labels = Array.from(query.matchAll(labelRegex)).map( + (match) => match[1] + ) - const searchQuery = parseSearchQuery(query) - - // build query string and save parameters - const queryString = buildQueryString(searchQuery, parameters) - - if (queryString) { - // add where clause from query string - queryBuilder - .innerJoinAndSelect('highlight.labels', 'label') - .andWhere(`(${queryString})`) - .setParameters(paramtersToObject(parameters)) - } + labels.forEach((label, index) => { + const alias = `label_${index}` + queryBuilder.innerJoin( + 'highlight.labels', + alias, + `LOWER(${alias}.name) = LOWER(:${alias})`, + { + [alias]: label, + } + ) + }) } return queryBuilder.getMany() diff --git a/packages/api/src/services/labels.ts b/packages/api/src/services/labels.ts index e3400520e..9e47ea65f 100644 --- a/packages/api/src/services/labels.ts +++ b/packages/api/src/services/labels.ts @@ -39,6 +39,23 @@ export const batchGetLabelsFromLibraryItemIds = async ( ) } +export const batchGetLabelsFromHighlightIds = async ( + highlightIds: readonly string[] +): Promise => { + const labels = await authTrx(async (tx) => + tx.getRepository(EntityLabel).find({ + where: { highlightId: In(highlightIds as string[]) }, + relations: ['label'], + }) + ) + + return highlightIds.map((highlightId) => + labels + .filter((label) => label.highlightId === highlightId) + .map((label) => label.label) + ) +} + export const findOrCreateLabels = async ( labels: CreateLabelInput[], userId: string diff --git a/packages/api/test/resolvers/highlight.test.ts b/packages/api/test/resolvers/highlight.test.ts index dc0a2c08e..34cc811eb 100644 --- a/packages/api/test/resolvers/highlight.test.ts +++ b/packages/api/test/resolvers/highlight.test.ts @@ -463,7 +463,7 @@ describe('Highlights API', () => { await saveLabelsInHighlight([label1], existingHighlights[1].id, user.id) const res = await graphqlRequest(query, authToken, { - query: `label:${labelName},${labelName1}`, + query: `label:"${labelName}" label:"${labelName1}"`, }).expect(200) const highlights = res.body.data.highlights.edges as Array expect(highlights).to.have.lengthOf(2)