summaryrefslogtreecommitdiff
path: root/packages/backend/src/core/SearchService.ts
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-02-03 14:31:26 -0500
committerHazelnoot <acomputerdog@gmail.com>2025-02-03 14:36:09 -0500
commita4e86758c1c53f4e623b6e8f613d4a6e34e96156 (patch)
treed09bf325b7f52512a1fe2a9d35f1953d2b310309 /packages/backend/src/core/SearchService.ts
parentmerge: Use package manager version from package.json (!883) (diff)
parentfix(build): corepackのバグの回避 (#15387) (diff)
downloadsharkey-a4e86758c1c53f4e623b6e8f613d4a6e34e96156.tar.gz
sharkey-a4e86758c1c53f4e623b6e8f613d4a6e34e96156.tar.bz2
sharkey-a4e86758c1c53f4e623b6e8f613d4a6e34e96156.zip
merge upstream 2025-02-03
Diffstat (limited to 'packages/backend/src/core/SearchService.ts')
-rw-r--r--packages/backend/src/core/SearchService.ts347
1 files changed, 189 insertions, 158 deletions
diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts
index 6dc3e85fc8..431cc0234e 100644
--- a/packages/backend/src/core/SearchService.ts
+++ b/packages/backend/src/core/SearchService.ts
@@ -6,16 +6,17 @@
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
-import type { Config } from '@/config.js';
+import { type Config, FulltextSearchProvider } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { MiNote } from '@/models/Note.js';
-import { MiUser } from '@/models/_.js';
import type { NotesRepository } from '@/models/_.js';
+import { MiUser } from '@/models/_.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { CacheService } from '@/core/CacheService.js';
import { QueryService } from '@/core/QueryService.js';
import { IdService } from '@/core/IdService.js';
+import { LoggerService } from '@/core/LoggerService.js';
import type { Index, MeiliSearch } from 'meilisearch';
type K = string;
@@ -27,12 +28,27 @@ type Q =
{ op: '<', k: K, v: number } |
{ op: '>=', k: K, v: number } |
{ op: '<=', k: K, v: number } |
- { op: 'is null', k: K} |
- { op: 'is not null', k: K} |
+ { op: 'is null', k: K } |
+ { op: 'is not null', k: K } |
{ op: 'and', qs: Q[] } |
{ op: 'or', qs: Q[] } |
{ op: 'not', q: Q };
+export type SearchOpts = {
+ userId?: MiNote['userId'] | null;
+ channelId?: MiNote['channelId'] | null;
+ host?: string | null;
+ filetype?: string | null;
+ order?: string | null;
+ disableMeili?: boolean | null;
+};
+
+export type SearchPagination = {
+ untilId?: MiNote['id'];
+ sinceId?: MiNote['id'];
+ limit: number;
+};
+
function compileValue(value: V): string {
if (typeof value === 'string') {
return `'${value}'`; // TODO: escape
@@ -64,7 +80,8 @@ function compileQuery(q: Q): string {
@Injectable()
export class SearchService {
private readonly meilisearchIndexScope: 'local' | 'global' | string[] = 'local';
- private meilisearchNoteIndex: Index | null = null;
+ private readonly meilisearchNoteIndex: Index | null = null;
+ private readonly provider: FulltextSearchProvider;
constructor(
@Inject(DI.config)
@@ -79,6 +96,7 @@ export class SearchService {
private cacheService: CacheService,
private queryService: QueryService,
private idService: IdService,
+ private loggerService: LoggerService,
) {
if (meilisearch) {
this.meilisearchNoteIndex = meilisearch.index(`${this.config.meilisearch?.index}---notes`);
@@ -110,189 +128,202 @@ export class SearchService {
if (this.config.meilisearch?.scope) {
this.meilisearchIndexScope = this.config.meilisearch.scope;
}
+
+ this.provider = config.fulltextSearch?.provider ?? 'sqlLike';
+ this.loggerService.getLogger('SearchService').info(`-- Provider: ${this.provider}`);
}
@bindThis
public async indexNote(note: MiNote): Promise<void> {
+ if (!this.meilisearch) return;
if (note.text == null && note.cw == null) return;
if (!['home', 'public'].includes(note.visibility)) return;
- if (this.meilisearch) {
- switch (this.meilisearchIndexScope) {
- case 'global':
- break;
+ switch (this.meilisearchIndexScope) {
+ case 'global':
+ break;
- case 'local':
- if (note.userHost == null) break;
- return;
+ case 'local':
+ if (note.userHost == null) break;
+ return;
- default: {
- if (note.userHost == null) break;
- if (this.meilisearchIndexScope.includes(note.userHost)) break;
- return;
- }
+ default: {
+ if (note.userHost == null) break;
+ if (this.meilisearchIndexScope.includes(note.userHost)) break;
+ return;
}
-
- await this.meilisearchNoteIndex?.addDocuments([{
- id: note.id,
- createdAt: this.idService.parse(note.id).date.getTime(),
- userId: note.userId,
- userHost: note.userHost,
- channelId: note.channelId,
- cw: note.cw,
- text: note.text,
- tags: note.tags,
- attachedFileTypes: note.attachedFileTypes,
- }], {
- primaryKey: 'id',
- });
}
+
+ await this.meilisearchNoteIndex?.addDocuments([{
+ id: note.id,
+ createdAt: this.idService.parse(note.id).date.getTime(),
+ userId: note.userId,
+ userHost: note.userHost,
+ channelId: note.channelId,
+ cw: note.cw,
+ text: note.text,
+ tags: note.tags,
+ attachedFileTypes: note.attachedFileTypes,
+ }], {
+ primaryKey: 'id',
+ });
}
@bindThis
public async unindexNote(note: MiNote): Promise<void> {
+ if (!this.meilisearch) return;
if (!['home', 'public'].includes(note.visibility)) return;
- if (this.meilisearch) {
- this.meilisearchNoteIndex!.deleteDocument(note.id);
- }
+ await this.meilisearchNoteIndex?.deleteDocument(note.id);
+ await this.meilisearchNoteIndex?.deleteDocument(note.id);
}
@bindThis
- public async searchNote(q: string, me: MiUser | null, opts: {
- userId?: MiNote['userId'] | null;
- channelId?: MiNote['channelId'] | null;
- host?: string | null;
- filetype?: string | null;
- order?: string | null;
- disableMeili?: boolean | null;
- }, pagination: {
- untilId?: MiNote['id'];
- sinceId?: MiNote['id'];
- limit?: number;
- }): Promise<MiNote[]> {
- if (this.meilisearch && !opts.disableMeili) {
- const filter: Q = {
- op: 'and',
- qs: [],
- };
- if (pagination.untilId) filter.qs.push({ op: '<', k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime() });
- if (pagination.sinceId) filter.qs.push({ op: '>', k: 'createdAt', v: this.idService.parse(pagination.sinceId).date.getTime() });
- if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId });
- if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId });
- if (opts.host) {
- if (opts.host === '.') {
- filter.qs.push({ op: 'is null', k: 'userHost' });
- } else {
- filter.qs.push({ op: '=', k: 'userHost', v: opts.host });
- }
+ public async searchNote(
+ q: string,
+ me: MiUser | null,
+ opts: SearchOpts,
+ pagination: SearchPagination,
+ ): Promise<MiNote[]> {
+ switch (this.provider) {
+ case 'sqlLike':
+ case 'sqlPgroonga': {
+ // ほとんど内容に差がないのでsqlLikeとsqlPgroongaを同じ処理にしている.
+ // 今後の拡張で差が出る用であれば関数を分ける.
+ return this.searchNoteByLike(q, me, opts, pagination);
}
- if (opts.filetype) {
- if (opts.filetype === 'image') {
- filter.qs.push({ op: 'or', qs: [
- { op: '=', k: 'attachedFileTypes', v: 'image/webp' },
- { op: '=', k: 'attachedFileTypes', v: 'image/png' },
- { op: '=', k: 'attachedFileTypes', v: 'image/jpeg' },
- { op: '=', k: 'attachedFileTypes', v: 'image/avif' },
- { op: '=', k: 'attachedFileTypes', v: 'image/apng' },
- { op: '=', k: 'attachedFileTypes', v: 'image/gif' },
- ] });
- } else if (opts.filetype === 'video') {
- filter.qs.push({ op: 'or', qs: [
- { op: '=', k: 'attachedFileTypes', v: 'video/mp4' },
- { op: '=', k: 'attachedFileTypes', v: 'video/webm' },
- { op: '=', k: 'attachedFileTypes', v: 'video/mpeg' },
- { op: '=', k: 'attachedFileTypes', v: 'video/x-m4v' },
- ] });
- } else if (opts.filetype === 'audio') {
- filter.qs.push({ op: 'or', qs: [
- { op: '=', k: 'attachedFileTypes', v: 'audio/mpeg' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/flac' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/wav' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/aac' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/webm' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/opus' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/ogg' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/x-m4a' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/mod' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/s3m' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/xm' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/it' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/x-mod' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/x-s3m' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/x-xm' },
- { op: '=', k: 'attachedFileTypes', v: 'audio/x-it' },
- ] });
- }
+ case 'meilisearch': {
+ return this.searchNoteByMeiliSearch(q, me, opts, pagination);
}
- const res = await this.meilisearchNoteIndex!.search(q, {
- sort: [`createdAt:${opts.order ? opts.order : 'desc'}`],
- matchingStrategy: 'all',
- attributesToRetrieve: ['id', 'createdAt'],
- filter: compileQuery(filter),
- limit: pagination.limit,
- });
- if (res.hits.length === 0) return [];
- const [
- userIdsWhoMeMuting,
- userIdsWhoBlockingMe,
- ] = me ? await Promise.all([
- this.cacheService.userMutingsCache.fetch(me.id),
- this.cacheService.userBlockedCache.fetch(me.id),
- ]) : [new Set<string>(), new Set<string>()];
- const notes = (await this.notesRepository.findBy({
- id: In(res.hits.map(x => x.id)),
- })).filter(note => {
- if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
- if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
- return true;
- });
- return notes.sort((a, b) => a.id > b.id ? -1 : 1);
+ default: {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const typeCheck: never = this.provider;
+ return [];
+ }
+ }
+ }
+
+ @bindThis
+ private async searchNoteByLike(
+ q: string,
+ me: MiUser | null,
+ opts: SearchOpts,
+ pagination: SearchPagination,
+ ): Promise<MiNote[]> {
+ const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId);
+
+ if (opts.userId) {
+ query.andWhere('note.userId = :userId', { userId: opts.userId });
+ } else if (opts.channelId) {
+ query.andWhere('note.channelId = :channelId', { channelId: opts.channelId });
+ }
+
+ query
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
+
+ if (this.config.fulltextSearch?.provider === 'sqlPgroonga') {
+ query.andWhere('note.text &@ :q', { q });
} else {
- const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId);
+ query.andWhere('LOWER(note.text) LIKE :q', { q: `%${ sqlLikeEscape(q.toLowerCase()) }%` });
+ }
- if (opts.userId) {
- query.andWhere('note.userId = :userId', { userId: opts.userId });
- } else if (opts.channelId) {
- query.andWhere('note.channelId = :channelId', { channelId: opts.channelId });
+ if (opts.host) {
+ if (opts.host === '.') {
+ query.andWhere('user.host IS NULL');
+ } else {
+ query.andWhere('user.host = :host', { host: opts.host });
}
+ }
- query
- .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` })
- .innerJoinAndSelect('note.user', 'user')
- .leftJoinAndSelect('note.reply', 'reply')
- .leftJoinAndSelect('note.renote', 'renote')
- .leftJoinAndSelect('reply.user', 'replyUser')
- .leftJoinAndSelect('renote.user', 'renoteUser');
+ if (opts.filetype) {
+ /* this is very ugly, but the "correct" solution would
+ be `and exists (select 1 from
+ unnest(note."attachedFileTypes") x(t) where t like
+ :type)` and I can't find a way to get TypeORM to
+ generate that; this hack works because `~*` is
+ "regexp match, ignoring case" and the stringified
+ version of an array of varchars (which is what
+ `attachedFileTypes` is) looks like `{foo,bar}`, so
+ we're looking for opts.filetype as the first half of
+ a MIME type, either at start of the array (after the
+ `{`) or later (after a `,`) */
+ query.andWhere('note."attachedFileTypes"::varchar ~* :type', { type: `[{,]${opts.filetype}/` });
+ }
- if (opts.host) {
- if (opts.host === '.') {
- query.andWhere('user.host IS NULL');
- } else {
- query.andWhere('user.host = :host', { host: opts.host });
- }
- }
+ this.queryService.generateVisibilityQuery(query, me);
+ if (me) this.queryService.generateMutedUserQuery(query, me);
+ if (me) this.queryService.generateBlockedUserQuery(query, me);
- if (opts.filetype) {
- /* this is very ugly, but the "correct" solution would
- be `and exists (select 1 from
- unnest(note."attachedFileTypes") x(t) where t like
- :type)` and I can't find a way to get TypeORM to
- generate that; this hack works because `~*` is
- "regexp match, ignoring case" and the stringified
- version of an array of varchars (which is what
- `attachedFileTypes` is) looks like `{foo,bar}`, so
- we're looking for opts.filetype as the first half of
- a MIME type, either at start of the array (after the
- `{`) or later (after a `,`) */
- query.andWhere(`note."attachedFileTypes"::varchar ~* :type`, { type: `[{,]${opts.filetype}/` });
- }
+ return await query.limit(pagination.limit).getMany();
+ }
- this.queryService.generateVisibilityQuery(query, me);
- if (me) this.queryService.generateMutedUserQuery(query, me);
- if (me) this.queryService.generateBlockedUserQuery(query, me);
+ @bindThis
+ private async searchNoteByMeiliSearch(
+ q: string,
+ me: MiUser | null,
+ opts: SearchOpts,
+ pagination: SearchPagination,
+ ): Promise<MiNote[]> {
+ if (!this.meilisearch || !this.meilisearchNoteIndex) {
+ throw new Error('MeiliSearch is not available');
+ }
+
+ const filter: Q = {
+ op: 'and',
+ qs: [],
+ };
+ if (pagination.untilId) filter.qs.push({
+ op: '<',
+ k: 'createdAt',
+ v: this.idService.parse(pagination.untilId).date.getTime(),
+ });
+ if (pagination.sinceId) filter.qs.push({
+ op: '>',
+ k: 'createdAt',
+ v: this.idService.parse(pagination.sinceId).date.getTime(),
+ });
+ if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId });
+ if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId });
+ if (opts.host) {
+ if (opts.host === '.') {
+ filter.qs.push({ op: 'is null', k: 'userHost' });
+ } else {
+ filter.qs.push({ op: '=', k: 'userHost', v: opts.host });
+ }
+ }
- return await query.limit(pagination.limit).getMany();
+ const res = await this.meilisearchNoteIndex.search(q, {
+ sort: ['createdAt:desc'],
+ matchingStrategy: 'all',
+ attributesToRetrieve: ['id', 'createdAt'],
+ filter: compileQuery(filter),
+ limit: pagination.limit,
+ });
+ if (res.hits.length === 0) {
+ return [];
}
+
+ const [
+ userIdsWhoMeMuting,
+ userIdsWhoBlockingMe,
+ ] = me
+ ? await Promise.all([
+ this.cacheService.userMutingsCache.fetch(me.id),
+ this.cacheService.userBlockedCache.fetch(me.id),
+ ])
+ : [new Set<string>(), new Set<string>()];
+ const notes = (await this.notesRepository.findBy({
+ id: In(res.hits.map(x => x.id)),
+ })).filter(note => {
+ if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
+ if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
+ return true;
+ });
+
+ return notes.sort((a, b) => a.id > b.id ? -1 : 1);
}
}