batch get labels from highlight id

This commit is contained in:
Hongbo Wu
2024-06-06 17:26:01 +08:00
parent 24457d9036
commit 70bc136d15
6 changed files with 51 additions and 165 deletions

View File

@ -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<ExpressContext, ResolverContext> = async ({
users: new DataLoader(async (ids: readonly string[]) =>
findUsersByIds(ids as string[])
),
highlightLabels: new DataLoader(batchGetLabelsFromHighlightIds),
},
}

View File

@ -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) {

View File

@ -60,6 +60,7 @@ export interface RequestContext {
publicItems: DataLoader<string, PublicItem | undefined>
subscriptions: DataLoader<string, Subscription | undefined>
users: DataLoader<string, User | undefined>
highlightLabels: DataLoader<string, Label[]>
}
}

View File

@ -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<Array<Highlight>> => {
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()

View File

@ -39,6 +39,23 @@ export const batchGetLabelsFromLibraryItemIds = async (
)
}
export const batchGetLabelsFromHighlightIds = async (
highlightIds: readonly string[]
): Promise<Label[][]> => {
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

View File

@ -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<HighlightEdge>
expect(highlights).to.have.lengthOf(2)