diff --git a/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search-docs.spec.ts.md b/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search-docs.spec.ts.md new file mode 100644 index 0000000000..74468b7106 --- /dev/null +++ b/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search-docs.spec.ts.md @@ -0,0 +1,51 @@ +# Snapshot report for `src/__tests__/e2e/indexer/search-docs.spec.ts` + +The actual snapshot is saved in `search-docs.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## should search docs by keyword + +> Snapshot 1 + + [ + { + blockId: 'block-0', + createdAt: '2025-04-22T00:00:00.000Z', + docId: 'doc-0', + highlight: 'test1 hello', + title: '', + updatedAt: '2025-04-22T00:00:00.000Z', + }, + { + blockId: 'block-2', + createdAt: '2025-03-22T00:00:00.000Z', + docId: 'doc-2', + highlight: 'test3 hello', + title: '', + updatedAt: '2025-03-22T03:00:01.000Z', + }, + { + blockId: 'block-1', + createdAt: '2021-04-22T00:00:00.000Z', + docId: 'doc-1', + highlight: 'test2 hello', + title: '', + updatedAt: '2021-04-22T00:00:00.000Z', + }, + ] + +## should search docs by keyword with limit 1 + +> Snapshot 1 + + [ + { + blockId: 'block-0', + createdAt: '2025-04-22T00:00:00.000Z', + docId: 'doc-0', + highlight: 'test1 hello', + title: '', + updatedAt: '2025-04-22T00:00:00.000Z', + }, + ] diff --git a/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search-docs.spec.ts.snap b/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search-docs.spec.ts.snap new file mode 100644 index 0000000000..219047b992 Binary files /dev/null and b/packages/backend/server/src/__tests__/e2e/indexer/__snapshots__/search-docs.spec.ts.snap differ diff --git a/packages/backend/server/src/__tests__/e2e/indexer/search-docs.spec.ts b/packages/backend/server/src/__tests__/e2e/indexer/search-docs.spec.ts new file mode 100644 index 0000000000..95fc5e1bbb --- /dev/null +++ b/packages/backend/server/src/__tests__/e2e/indexer/search-docs.spec.ts @@ -0,0 +1,182 @@ +import { indexerSearchDocsQuery, SearchTable } from '@affine/graphql'; +import { omit } from 'lodash-es'; + +import { IndexerService } from '../../../plugins/indexer/service'; +import { Mockers } from '../../mocks'; +import { app, e2e } from '../test'; + +e2e('should search docs by keyword', async t => { + const owner = await app.signup(); + + const workspace = await app.create(Mockers.Workspace, { + owner, + }); + + const indexerService = app.get(IndexerService); + + await indexerService.write( + SearchTable.block, + [ + { + docId: 'doc-0', + workspaceId: workspace.id, + content: 'test1 hello', + flavour: 'markdown', + blockId: 'block-0', + createdByUserId: owner.id, + updatedByUserId: owner.id, + createdAt: new Date('2025-04-22T00:00:00.000Z'), + updatedAt: new Date('2025-04-22T00:00:00.000Z'), + }, + { + docId: 'doc-1', + workspaceId: workspace.id, + content: 'test2 hello', + flavour: 'markdown', + blockId: 'block-1', + refDocId: ['doc-0'], + ref: ['{"foo": "bar1"}'], + createdByUserId: owner.id, + updatedByUserId: owner.id, + createdAt: new Date('2021-04-22T00:00:00.000Z'), + updatedAt: new Date('2021-04-22T00:00:00.000Z'), + }, + { + docId: 'doc-2', + workspaceId: workspace.id, + content: 'test3 hello', + flavour: 'markdown', + blockId: 'block-2', + refDocId: ['doc-0', 'doc-2'], + ref: ['{"foo": "bar1"}', '{"foo": "bar3"}'], + createdByUserId: owner.id, + updatedByUserId: owner.id, + createdAt: new Date('2025-03-22T00:00:00.000Z'), + updatedAt: new Date('2025-03-22T03:00:01.000Z'), + }, + ], + { + refresh: true, + } + ); + + const result = await app.gql({ + query: indexerSearchDocsQuery, + variables: { + id: workspace.id, + input: { + keyword: 'hello', + }, + }, + }); + + t.is(result.workspace.searchDocs.length, 3); + t.snapshot( + result.workspace.searchDocs.map(doc => + omit(doc, 'createdByUser', 'updatedByUser') + ) + ); +}); + +e2e('should search docs by keyword with limit 1', async t => { + const owner = await app.signup(); + + const workspace = await app.create(Mockers.Workspace, { + owner, + }); + + const indexerService = app.get(IndexerService); + + await indexerService.write( + SearchTable.block, + [ + { + docId: 'doc-0', + workspaceId: workspace.id, + content: 'test1 hello', + flavour: 'markdown', + blockId: 'block-0', + createdByUserId: owner.id, + updatedByUserId: owner.id, + createdAt: new Date('2025-04-22T00:00:00.000Z'), + updatedAt: new Date('2025-04-22T00:00:00.000Z'), + }, + { + docId: 'doc-1', + workspaceId: workspace.id, + content: 'test2 hello', + flavour: 'markdown', + blockId: 'block-1', + refDocId: ['doc-0'], + ref: ['{"foo": "bar1"}'], + createdByUserId: owner.id, + updatedByUserId: owner.id, + createdAt: new Date('2021-04-22T00:00:00.000Z'), + updatedAt: new Date('2021-04-22T00:00:00.000Z'), + }, + { + docId: 'doc-2', + workspaceId: workspace.id, + content: 'test3 hello', + flavour: 'markdown', + blockId: 'block-2', + refDocId: ['doc-0', 'doc-2'], + ref: ['{"foo": "bar1"}', '{"foo": "bar3"}'], + createdByUserId: owner.id, + updatedByUserId: owner.id, + createdAt: new Date('2025-03-22T00:00:00.000Z'), + updatedAt: new Date('2025-03-22T03:00:01.000Z'), + }, + ], + { + refresh: true, + } + ); + + const result = await app.gql({ + query: indexerSearchDocsQuery, + variables: { + id: workspace.id, + input: { + keyword: 'hello', + limit: 1, + }, + }, + }); + + t.is(result.workspace.searchDocs.length, 1); + t.snapshot( + result.workspace.searchDocs.map(doc => + omit(doc, 'createdByUser', 'updatedByUser') + ) + ); +}); + +e2e( + 'should search docs by keyword failed when workspace is no permission', + async t => { + const owner = await app.signup(); + + const workspace = await app.create(Mockers.Workspace, { + owner, + }); + + // signup another user + await app.signup(); + + await t.throwsAsync( + app.gql({ + query: indexerSearchDocsQuery, + variables: { + id: workspace.id, + input: { + keyword: 'hello', + }, + }, + }), + { + message: /You do not have permission to access Space/, + } + ); + } +); diff --git a/packages/backend/server/src/plugins/indexer/resolver.ts b/packages/backend/server/src/plugins/indexer/resolver.ts index e4e988f185..4b8c6acd9a 100644 --- a/packages/backend/server/src/plugins/indexer/resolver.ts +++ b/packages/backend/server/src/plugins/indexer/resolver.ts @@ -10,6 +10,8 @@ import { IndexerService, SearchNodeWithMeta } from './service'; import { AggregateInput, AggregateResultObjectType, + SearchDocObjectType, + SearchDocsInput, SearchInput, SearchQueryOccur, SearchQueryType, @@ -86,6 +88,29 @@ export class IndexerResolver { }; } + @ResolveField(() => [SearchDocObjectType], { + description: 'Search docs by keyword', + }) + async searchDocs( + @CurrentUser() me: UserType, + @Parent() workspace: WorkspaceType, + @Args('input') input: SearchDocsInput + ): Promise { + const docs = await this.indexer.searchDocsByKeyword( + workspace.id, + input.keyword, + { + limit: input.limit, + } + ); + + const needs = await this.ac + .user(me.id) + .workspace(workspace.id) + .docs(docs, 'Doc.Read'); + return needs; + } + #addWorkspaceFilter( workspace: WorkspaceType, input: SearchInput | AggregateInput diff --git a/packages/backend/server/src/plugins/indexer/types.ts b/packages/backend/server/src/plugins/indexer/types.ts index 1d20f3c845..ae7c161c51 100644 --- a/packages/backend/server/src/plugins/indexer/types.ts +++ b/packages/backend/server/src/plugins/indexer/types.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/graphql'; import { GraphQLJSONObject } from 'graphql-scalars'; +import { PublicUserType } from '../../core/user'; import { PublicUser } from '../../models'; import { SearchTable } from './tables'; @@ -171,6 +172,18 @@ export class AggregateInput { options!: AggregateOptions; } +@InputType() +export class SearchDocsInput { + @Field(() => String) + keyword!: string; + + @Field({ + nullable: true, + description: 'Limit the number of docs to return, default is 20', + }) + limit?: number; +} + @ObjectType() export class BlockObjectType { @Field(() => [String], { nullable: true }) @@ -320,3 +333,30 @@ export class AggregateResultObjectType { @Field(() => SearchResultPagination) pagination!: SearchResultPagination; } + +@ObjectType() +export class SearchDocObjectType implements Partial { + @Field(() => String) + docId!: string; + + @Field(() => String) + title!: string; + + @Field(() => String) + blockId!: string; + + @Field(() => String) + highlight!: string; + + @Field(() => Date) + createdAt!: Date; + + @Field(() => Date) + updatedAt!: Date; + + @Field(() => PublicUserType, { nullable: true }) + createdByUser?: PublicUserType; + + @Field(() => PublicUserType, { nullable: true }) + updatedByUser?: PublicUserType; +} diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 836920fcb2..c5615615c0 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -1502,6 +1502,24 @@ type SameSubscriptionRecurringDataType { recurring: String! } +type SearchDocObjectType { + blockId: String! + createdAt: DateTime! + createdByUser: PublicUserType + docId: String! + highlight: String! + title: String! + updatedAt: DateTime! + updatedByUser: PublicUserType +} + +input SearchDocsInput { + keyword: String! + + """Limit the number of docs to return, default is 20""" + limit: Int +} + input SearchHighlight { before: String! end: String! @@ -2074,6 +2092,9 @@ type WorkspaceType { """Search a specific table""" search(input: SearchInput!): SearchResultObjectType! + """Search docs by keyword""" + searchDocs(input: SearchDocsInput!): [SearchDocObjectType!]! + """The team subscription of the workspace, if exists.""" subscription: SubscriptionType diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 24a49075b5..9e9c40b4b4 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -1419,6 +1419,33 @@ export const indexerAggregateQuery = { }`, }; +export const indexerSearchDocsQuery = { + id: 'indexerSearchDocsQuery' as const, + op: 'indexerSearchDocs', + query: `query indexerSearchDocs($id: String!, $input: SearchDocsInput!) { + workspace(id: $id) { + searchDocs(input: $input) { + docId + title + blockId + highlight + createdAt + updatedAt + createdByUser { + id + name + avatarUrl + } + updatedByUser { + id + name + avatarUrl + } + } + } +}`, +}; + export const indexerSearchQuery = { id: 'indexerSearchQuery' as const, op: 'indexerSearch', diff --git a/packages/common/graphql/src/graphql/indexer-search-docs.gql b/packages/common/graphql/src/graphql/indexer-search-docs.gql new file mode 100644 index 0000000000..b1a7e78ddb --- /dev/null +++ b/packages/common/graphql/src/graphql/indexer-search-docs.gql @@ -0,0 +1,22 @@ +query indexerSearchDocs($id: String!, $input: SearchDocsInput!) { + workspace(id: $id) { + searchDocs(input: $input) { + docId + title + blockId + highlight + createdAt + updatedAt + createdByUser { + id + name + avatarUrl + } + updatedByUser { + id + name + avatarUrl + } + } + } +} diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index f7918f1b48..05a1baa13c 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -2063,6 +2063,24 @@ export interface SameSubscriptionRecurringDataType { recurring: Scalars['String']['output']; } +export interface SearchDocObjectType { + __typename?: 'SearchDocObjectType'; + blockId: Scalars['String']['output']; + createdAt: Scalars['DateTime']['output']; + createdByUser: Maybe; + docId: Scalars['String']['output']; + highlight: Scalars['String']['output']; + title: Scalars['String']['output']; + updatedAt: Scalars['DateTime']['output']; + updatedByUser: Maybe; +} + +export interface SearchDocsInput { + keyword: Scalars['String']['input']; + /** Limit the number of docs to return, default is 20 */ + limit?: InputMaybe; +} + export interface SearchHighlight { before: Scalars['String']['input']; end: Scalars['String']['input']; @@ -2649,6 +2667,8 @@ export interface WorkspaceType { role: Permission; /** Search a specific table */ search: SearchResultObjectType; + /** Search docs by keyword */ + searchDocs: Array; /** The team subscription of the workspace, if exists. */ subscription: Maybe; /** if workspace is team workspace */ @@ -2700,6 +2720,10 @@ export interface WorkspaceTypeSearchArgs { input: SearchInput; } +export interface WorkspaceTypeSearchDocsArgs { + input: SearchDocsInput; +} + export interface WorkspaceUserType { __typename?: 'WorkspaceUserType'; avatarUrl: Maybe; @@ -4339,6 +4363,39 @@ export type IndexerAggregateQuery = { }; }; +export type IndexerSearchDocsQueryVariables = Exact<{ + id: Scalars['String']['input']; + input: SearchDocsInput; +}>; + +export type IndexerSearchDocsQuery = { + __typename?: 'Query'; + workspace: { + __typename?: 'WorkspaceType'; + searchDocs: Array<{ + __typename?: 'SearchDocObjectType'; + docId: string; + title: string; + blockId: string; + highlight: string; + createdAt: string; + updatedAt: string; + createdByUser: { + __typename?: 'PublicUserType'; + id: string; + name: string; + avatarUrl: string | null; + } | null; + updatedByUser: { + __typename?: 'PublicUserType'; + id: string; + name: string; + avatarUrl: string | null; + } | null; + }>; + }; +}; + export type IndexerSearchQueryVariables = Exact<{ id: Scalars['String']['input']; input: SearchInput; @@ -5302,6 +5359,11 @@ export type Queries = variables: IndexerAggregateQueryVariables; response: IndexerAggregateQuery; } + | { + name: 'indexerSearchDocsQuery'; + variables: IndexerSearchDocsQueryVariables; + response: IndexerSearchDocsQuery; + } | { name: 'indexerSearchQuery'; variables: IndexerSearchQueryVariables;