feat(server): add search docs by keyword gql api (#12866)

close AI-220









#### PR Dependency Tree


* **PR #12866** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced a new document search capability, allowing users to search
for documents by keyword within a workspace.
- Search results include document details such as title, highlights,
creation and update timestamps, and creator/updater information.
  - Added support for limiting the number of search results returned.
- **Tests**
- Added comprehensive end-to-end and snapshot tests to ensure accuracy
and access control for the new search functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
fengmk2 2025-06-20 19:29:37 +08:00 committed by GitHub
parent 10e981aa6d
commit 011f92f7da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 430 additions and 0 deletions

View File

@ -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 <b>hello</b>',
title: '',
updatedAt: '2025-04-22T00:00:00.000Z',
},
{
blockId: 'block-2',
createdAt: '2025-03-22T00:00:00.000Z',
docId: 'doc-2',
highlight: 'test3 <b>hello</b>',
title: '',
updatedAt: '2025-03-22T03:00:01.000Z',
},
{
blockId: 'block-1',
createdAt: '2021-04-22T00:00:00.000Z',
docId: 'doc-1',
highlight: 'test2 <b>hello</b>',
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 <b>hello</b>',
title: '',
updatedAt: '2025-04-22T00:00:00.000Z',
},
]

View File

@ -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/,
}
);
}
);

View File

@ -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<SearchDocObjectType[]> {
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

View File

@ -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<SearchDoc> {
@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;
}

View File

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

View File

@ -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',

View File

@ -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
}
}
}
}

View File

@ -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<PublicUserType>;
docId: Scalars['String']['output'];
highlight: Scalars['String']['output'];
title: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
updatedByUser: Maybe<PublicUserType>;
}
export interface SearchDocsInput {
keyword: Scalars['String']['input'];
/** Limit the number of docs to return, default is 20 */
limit?: InputMaybe<Scalars['Int']['input']>;
}
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<SearchDocObjectType>;
/** The team subscription of the workspace, if exists. */
subscription: Maybe<SubscriptionType>;
/** 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<Scalars['String']['output']>;
@ -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;