fix(server): DriveFile related N+1 query when call note packMany (again) (#10190)
* Revert "Revert "fix(server): DriveFile related N+1 query when call note packMany (#10133)""
This reverts commit a7c82eeabc
.
* packManyByIdsMap: 存在チェックをしてなかったものは null を入れるように
* Note.packMany で reply とか renote がもうあったらそのファイルも引く
* テストを書く
* fix test
* fix test
* fix test
* fix test
This commit is contained in:
parent
e4fc9ea816
commit
49f0837729
4 changed files with 168 additions and 5 deletions
|
@ -1,5 +1,5 @@
|
||||||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource, In } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { NotesRepository, DriveFilesRepository } from '@/models/index.js';
|
import type { NotesRepository, DriveFilesRepository } from '@/models/index.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
|
@ -21,6 +21,7 @@ type PackOptions = {
|
||||||
};
|
};
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||||
|
import { isNotNull } from '@/misc/is-not-null.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DriveFileEntityService {
|
export class DriveFileEntityService {
|
||||||
|
@ -255,10 +256,33 @@ export class DriveFileEntityService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async packMany(
|
public async packMany(
|
||||||
files: (DriveFile['id'] | DriveFile)[],
|
files: DriveFile[],
|
||||||
options?: PackOptions,
|
options?: PackOptions,
|
||||||
): Promise<Packed<'DriveFile'>[]> {
|
): Promise<Packed<'DriveFile'>[]> {
|
||||||
const items = await Promise.all(files.map(f => this.packNullable(f, options)));
|
const items = await Promise.all(files.map(f => this.packNullable(f, options)));
|
||||||
return items.filter((x): x is Packed<'DriveFile'> => x != null);
|
return items.filter((x): x is Packed<'DriveFile'> => x != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async packManyByIdsMap(
|
||||||
|
fileIds: DriveFile['id'][],
|
||||||
|
options?: PackOptions,
|
||||||
|
): Promise<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>> {
|
||||||
|
const files = await this.driveFilesRepository.findBy({ id: In(fileIds) });
|
||||||
|
const packedFiles = await this.packMany(files, options);
|
||||||
|
const map = new Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f]));
|
||||||
|
for (const id of fileIds) {
|
||||||
|
if (!map.has(id)) map.set(id, null);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async packManyByIds(
|
||||||
|
fileIds: DriveFile['id'][],
|
||||||
|
options?: PackOptions,
|
||||||
|
): Promise<Packed<'DriveFile'>[]> {
|
||||||
|
const filesMap = await this.packManyByIdsMap(fileIds, options);
|
||||||
|
return fileIds.map(id => filesMap.get(id)).filter(isNotNull);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,8 @@ export class GalleryPostEntityService {
|
||||||
title: post.title,
|
title: post.title,
|
||||||
description: post.description,
|
description: post.description,
|
||||||
fileIds: post.fileIds,
|
fileIds: post.fileIds,
|
||||||
files: this.driveFileEntityService.packMany(post.fileIds),
|
// TODO: packMany causes N+1 queries
|
||||||
|
files: this.driveFileEntityService.packManyByIds(post.fileIds),
|
||||||
tags: post.tags.length > 0 ? post.tags : undefined,
|
tags: post.tags.length > 0 ? post.tags : undefined,
|
||||||
isSensitive: post.isSensitive,
|
isSensitive: post.isSensitive,
|
||||||
likedCount: post.likedCount,
|
likedCount: post.likedCount,
|
||||||
|
|
|
@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js';
|
||||||
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
|
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
|
||||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js';
|
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { isNotNull } from '@/misc/is-not-null.js';
|
||||||
import type { OnModuleInit } from '@nestjs/common';
|
import type { OnModuleInit } from '@nestjs/common';
|
||||||
import type { CustomEmojiService } from '../CustomEmojiService.js';
|
import type { CustomEmojiService } from '../CustomEmojiService.js';
|
||||||
import type { ReactionService } from '../ReactionService.js';
|
import type { ReactionService } from '../ReactionService.js';
|
||||||
|
@ -248,6 +249,21 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async packAttachedFiles(fileIds: Note['fileIds'], packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>): Promise<Packed<'DriveFile'>[]> {
|
||||||
|
const missingIds = [];
|
||||||
|
for (const id of fileIds) {
|
||||||
|
if (!packedFiles.has(id)) missingIds.push(id);
|
||||||
|
}
|
||||||
|
if (missingIds.length) {
|
||||||
|
const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds);
|
||||||
|
for (const [k, v] of additionalMap) {
|
||||||
|
packedFiles.set(k, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fileIds.map(id => packedFiles.get(id)).filter(isNotNull);
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async pack(
|
public async pack(
|
||||||
src: Note['id'] | Note,
|
src: Note['id'] | Note,
|
||||||
|
@ -257,6 +273,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
skipHide?: boolean;
|
skipHide?: boolean;
|
||||||
_hint_?: {
|
_hint_?: {
|
||||||
myReactions: Map<Note['id'], NoteReaction | null>;
|
myReactions: Map<Note['id'], NoteReaction | null>;
|
||||||
|
packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
): Promise<Packed<'Note'>> {
|
): Promise<Packed<'Note'>> {
|
||||||
|
@ -284,6 +301,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
const reactionEmojiNames = Object.keys(note.reactions)
|
const reactionEmojiNames = Object.keys(note.reactions)
|
||||||
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
|
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
|
||||||
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
|
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
|
||||||
|
const packedFiles = options?._hint_?.packedFiles;
|
||||||
|
|
||||||
const packed: Packed<'Note'> = await awaitAll({
|
const packed: Packed<'Note'> = await awaitAll({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
|
@ -304,7 +322,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined,
|
emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined,
|
||||||
tags: note.tags.length > 0 ? note.tags : undefined,
|
tags: note.tags.length > 0 ? note.tags : undefined,
|
||||||
fileIds: note.fileIds,
|
fileIds: note.fileIds,
|
||||||
files: this.driveFileEntityService.packMany(note.fileIds),
|
files: packedFiles != null ? this.packAttachedFiles(note.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(note.fileIds),
|
||||||
replyId: note.replyId,
|
replyId: note.replyId,
|
||||||
renoteId: note.renoteId,
|
renoteId: note.renoteId,
|
||||||
channelId: note.channelId ?? undefined,
|
channelId: note.channelId ?? undefined,
|
||||||
|
@ -388,11 +406,15 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
|
await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
|
||||||
|
// TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
|
||||||
|
const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull);
|
||||||
|
const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds);
|
||||||
|
|
||||||
return await Promise.all(notes.map(n => this.pack(n, me, {
|
return await Promise.all(notes.map(n => this.pack(n, me, {
|
||||||
...options,
|
...options,
|
||||||
_hint_: {
|
_hint_: {
|
||||||
myReactions: myReactionsMap,
|
myReactions: myReactionsMap,
|
||||||
|
packedFiles,
|
||||||
},
|
},
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
import * as assert from 'assert';
|
import * as assert from 'assert';
|
||||||
import { Note } from '@/models/entities/Note.js';
|
import { Note } from '@/models/entities/Note.js';
|
||||||
import { signup, post, uploadUrl, startServer, initTestDb, api } from '../utils.js';
|
import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js';
|
||||||
import type { INestApplicationContext } from '@nestjs/common';
|
import type { INestApplicationContext } from '@nestjs/common';
|
||||||
|
|
||||||
describe('Note', () => {
|
describe('Note', () => {
|
||||||
|
@ -213,6 +213,122 @@ describe('Note', () => {
|
||||||
assert.deepStrictEqual(noteDoc.mentions, [bob.id]);
|
assert.deepStrictEqual(noteDoc.mentions, [bob.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('添付ファイル情報', () => {
|
||||||
|
test('ファイルを添付した場合、投稿成功時にファイル情報入りのレスポンスが帰ってくる', async () => {
|
||||||
|
const file = await uploadFile(alice);
|
||||||
|
const res = await api('/notes/create', {
|
||||||
|
fileIds: [file.body.id],
|
||||||
|
}, alice);
|
||||||
|
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
|
||||||
|
assert.strictEqual(res.body.createdNote.files.length, 1);
|
||||||
|
assert.strictEqual(res.body.createdNote.files[0].id, file.body.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ファイルを添付した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
|
||||||
|
const file = await uploadFile(alice);
|
||||||
|
const createdNote = await api('/notes/create', {
|
||||||
|
fileIds: [file.body.id],
|
||||||
|
}, alice);
|
||||||
|
|
||||||
|
assert.strictEqual(createdNote.status, 200);
|
||||||
|
|
||||||
|
const res = await api('/notes', {
|
||||||
|
withFiles: true,
|
||||||
|
}, alice);
|
||||||
|
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.strictEqual(Array.isArray(res.body), true);
|
||||||
|
const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id);
|
||||||
|
assert.notEqual(myNote, null);
|
||||||
|
assert.strictEqual(myNote.files.length, 1);
|
||||||
|
assert.strictEqual(myNote.files[0].id, file.body.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ファイルが添付されたノートをリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
|
||||||
|
const file = await uploadFile(alice);
|
||||||
|
const createdNote = await api('/notes/create', {
|
||||||
|
fileIds: [file.body.id],
|
||||||
|
}, alice);
|
||||||
|
|
||||||
|
assert.strictEqual(createdNote.status, 200);
|
||||||
|
|
||||||
|
const renoted = await api('/notes/create', {
|
||||||
|
renoteId: createdNote.body.createdNote.id,
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(renoted.status, 200);
|
||||||
|
|
||||||
|
const res = await api('/notes', {
|
||||||
|
renote: true,
|
||||||
|
}, alice);
|
||||||
|
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.strictEqual(Array.isArray(res.body), true);
|
||||||
|
const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
|
||||||
|
assert.notEqual(myNote, null);
|
||||||
|
assert.strictEqual(myNote.renote.files.length, 1);
|
||||||
|
assert.strictEqual(myNote.renote.files[0].id, file.body.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ファイルが添付されたノートに返信した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
|
||||||
|
const file = await uploadFile(alice);
|
||||||
|
const createdNote = await api('/notes/create', {
|
||||||
|
fileIds: [file.body.id],
|
||||||
|
}, alice);
|
||||||
|
|
||||||
|
assert.strictEqual(createdNote.status, 200);
|
||||||
|
|
||||||
|
const reply = await api('/notes/create', {
|
||||||
|
replyId: createdNote.body.createdNote.id,
|
||||||
|
text: 'this is reply',
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(reply.status, 200);
|
||||||
|
|
||||||
|
const res = await api('/notes', {
|
||||||
|
reply: true,
|
||||||
|
}, alice);
|
||||||
|
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.strictEqual(Array.isArray(res.body), true);
|
||||||
|
const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id);
|
||||||
|
assert.notEqual(myNote, null);
|
||||||
|
assert.strictEqual(myNote.reply.files.length, 1);
|
||||||
|
assert.strictEqual(myNote.reply.files[0].id, file.body.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ファイルが添付されたノートへの返信をリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
|
||||||
|
const file = await uploadFile(alice);
|
||||||
|
const createdNote = await api('/notes/create', {
|
||||||
|
fileIds: [file.body.id],
|
||||||
|
}, alice);
|
||||||
|
|
||||||
|
assert.strictEqual(createdNote.status, 200);
|
||||||
|
|
||||||
|
const reply = await api('/notes/create', {
|
||||||
|
replyId: createdNote.body.createdNote.id,
|
||||||
|
text: 'this is reply',
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(reply.status, 200);
|
||||||
|
|
||||||
|
const renoted = await api('/notes/create', {
|
||||||
|
renoteId: reply.body.createdNote.id,
|
||||||
|
}, alice);
|
||||||
|
assert.strictEqual(renoted.status, 200);
|
||||||
|
|
||||||
|
const res = await api('/notes', {
|
||||||
|
renote: true,
|
||||||
|
}, alice);
|
||||||
|
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.strictEqual(Array.isArray(res.body), true);
|
||||||
|
const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
|
||||||
|
assert.notEqual(myNote, null);
|
||||||
|
assert.strictEqual(myNote.renote.reply.files.length, 1);
|
||||||
|
assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('notes/create', () => {
|
describe('notes/create', () => {
|
||||||
test('投票を添付できる', async () => {
|
test('投票を添付できる', async () => {
|
||||||
const res = await api('/notes/create', {
|
const res = await api('/notes/create', {
|
||||||
|
|
Loading…
Reference in a new issue