refactor QueryService to use EXISTS instead of IN for most queries

This commit is contained in:
Hazelnoot 2025-06-02 16:58:54 -04:00
parent 825f219368
commit cbefbd2a33

View file

@ -4,13 +4,13 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Brackets, ObjectLiteral } from 'typeorm'; import { Brackets, Not, WhereExpressionBuilder } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta } from '@/models/_.js'; import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta, InstancesRepository, MiInstance } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { SelectQueryBuilder } from 'typeorm'; import type { SelectQueryBuilder, FindOptionsWhere, ObjectLiteral } from 'typeorm';
@Injectable() @Injectable()
export class QueryService { export class QueryService {
@ -36,6 +36,9 @@ export class QueryService {
@Inject(DI.renoteMutingsRepository) @Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository, private renoteMutingsRepository: RenoteMutingsRepository,
@Inject(DI.instancesRepository)
private readonly instancesRepository: InstancesRepository,
@Inject(DI.meta) @Inject(DI.meta)
private meta: MiMeta, private meta: MiMeta,
@ -72,215 +75,278 @@ export class QueryService {
// ここでいうBlockedは被Blockedの意 // ここでいうBlockedは被Blockedの意
@bindThis @bindThis
public generateBlockedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateBlockedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('blocking.blockerId')
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
// 投稿の作者にブロックされていない かつ // 投稿の作者にブロックされていない かつ
// 投稿の返信先の作者にブロックされていない かつ // 投稿の返信先の作者にブロックされていない かつ
// 投稿の引用元の作者にブロックされていない // 投稿の引用元の作者にブロックされていない
q return this.excludeBlockingUser(q, 'note.userId', ':meId')
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => {
qb this.excludeBlockingUser(qb, 'note.replyUserId', ':meId')
.where('note.replyUserId IS NULL') .orWhere('note.replyUserId IS NULL');
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
})) }))
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => {
qb this.excludeBlockingUser(qb, 'note.renoteUserId', ':meId')
.where('note.renoteUserId IS NULL') .orWhere('note.renoteUserId IS NULL');
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); }))
})); .setParameters({ meId: me.id });
q.setParameters(blockingQuery.getParameters());
} }
@bindThis @bindThis
public generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateBlockQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') this.excludeBlockingUser(q, ':meId', 'user.id');
.select('blocking.blockeeId') this.excludeBlockingUser(q, 'user.id', ':me.id');
.where('blocking.blockerId = :blockerId', { blockerId: me.id }); return q.setParameters({ meId: me.id });
const blockedQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('blocking.blockerId')
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`);
q.setParameters(blockingQuery.getParameters());
q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`);
q.setParameters(blockedQuery.getParameters());
} }
@bindThis @bindThis
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateMutedNoteThreadQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') return this.excludeMutingThread(q, ':meId', 'note.id')
.select('threadMuted.threadId') .andWhere(new Brackets(qb => {
.where('threadMuted.userId = :userId', { userId: me.id }); this.excludeMutingThread(qb, ':meId', 'note.threadId')
.orWhere('note.threadId IS NULL');
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); }))
q.andWhere(new Brackets(qb => { .setParameters({ meId: me.id });
qb
.where('note.threadId IS NULL')
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
}));
q.setParameters(mutedQuery.getParameters());
} }
@bindThis @bindThis
public generateMutedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void { public generateMutedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: me.id });
if (exclude) {
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
}
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
.select('user_profile.mutedInstances')
.where('user_profile.userId = :muterId', { muterId: me.id });
// 投稿の作者をミュートしていない かつ // 投稿の作者をミュートしていない かつ
// 投稿の返信先の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない // 投稿の引用元の作者をミュートしていない
q this.excludeMutingUser(q, ':meId', 'note.userId', exclude)
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => {
qb this.excludeMutingUser(qb, ':meId', 'note.replyUserId', exclude)
.where('note.replyUserId IS NULL') .orWhere('note.replyUserId IS NULL');
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
})) }))
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => {
qb this.excludeMutingUser(qb, ':meId', 'note.renoteUserId', exclude)
.where('note.renoteUserId IS NULL') .orWhere('note.renoteUserId IS NULL');
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
}))
// mute instances
.andWhere(new Brackets(qb => {
qb
.andWhere('note.userHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
}))
.andWhere(new Brackets(qb => {
qb
.where('note.replyUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
}))
.andWhere(new Brackets(qb => {
qb
.where('note.renoteUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
})); }));
q.setParameters(mutingQuery.getParameters()); // mute instances
q.setParameters(mutingInstanceQuery.getParameters()); this.excludeMutingInstance(q, ':meId', 'note.userHost')
.andWhere(new Brackets(qb => {
this.excludeMutingInstance(qb, ':meId', 'note.replyUserHost')
.orWhere('note.replyUserHost IS NULL');
}))
.andWhere(new Brackets(qb => {
this.excludeMutingInstance(qb, ':meId', 'note.renoteUserHost')
.orWhere('note.renoteUserHost IS NULL');
}));
return q.setParameters({ meId: me.id });
} }
@bindThis @bindThis
public generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateMutedUserQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') return this.excludeMutingUser(q, ':meId', 'user.id')
.select('muting.muteeId') .setParameters({ meId: me.id });
.where('muting.muterId = :muterId', { muterId: me.id });
q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
q.setParameters(mutingQuery.getParameters());
} }
// This intentionally skips isSuspended, isDeleted, makeNotesFollowersOnlyBefore, makeNotesHiddenBefore, and requireSigninToViewContents.
// NoteEntityService checks these automatically and calls hideNote() to hide them without breaking threads.
// For moderation purposes, you can set isSilenced to forcibly hide existing posts by a user.
@bindThis @bindThis
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void { public generateVisibilityQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
// This code must always be synchronized with the checks in Notes.isVisibleForMe. // This code must always be synchronized with the checks in Notes.isVisibleForMe.
if (me == null) { return q.andWhere(new Brackets(qb => {
q.andWhere(new Brackets(qb => { // Public post
qb qb.orWhere('note.visibility = \'public\'')
.where('note.visibility = \'public\'') .orWhere('note.visibility = \'home\'');
.orWhere('note.visibility = \'home\'');
}));
} else {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :meId');
q.andWhere(new Brackets(qb => { if (me != null) {
qb qb
// 公開投稿である // My post
.where(new Brackets(qb => { .orWhere(':meId = note.userId')
qb // Reply to me
.where('note.visibility = \'public\'') .orWhere(':meId = note.replyUserId')
.orWhere('note.visibility = \'home\''); // DM to me
})) .orWhere(':meId = ANY (note.visibleUserIds)')
// または 自分自身 // Mentions me
.orWhere('note.userId = :meId') .orWhere(':meId = ANY (note.mentions)')
// または 自分宛て // Followers-only post
.orWhere(':meIdAsList <@ note.visibleUserIds')
.orWhere(new Brackets(qb => { .orWhere(new Brackets(qb => {
qb
// または フォロワー宛ての投稿であり、 // または フォロワー宛ての投稿であり、
.where('note.visibility = \'followers\'') this.addFollowingUser(qb, ':meId', 'note.userId')
.andWhere(new Brackets(qb => { .andWhere('note.visibility = \'followers\'');
qb
// 自分がフォロワーである
.where(`note.userId IN (${ followingQuery.getQuery() })`)
// または 自分の投稿へのリプライ
.orWhere('note.replyUserId = :meId')
.orWhere(':meIdAsList <@ note.mentions');
}));
})); }));
}));
q.setParameters({ meId: me.id, meIdAsList: [me.id] }); q.setParameters({ meId: me.id });
} }
}));
} }
@bindThis @bindThis
public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateMutedUserRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') return q.andWhere(new Brackets(qb => {
.select('renote_muting.muteeId') this.excludeMutingRenote(qb, ':meId', 'note.userId')
.where('renote_muting.muterId = :muterId', { muterId: me.id });
q.andWhere(new Brackets(qb => {
qb
.orWhere('note.renoteId IS NULL') .orWhere('note.renoteId IS NULL')
.orWhere('note.text IS NOT NULL') .orWhere('note.text IS NOT NULL')
.orWhere('note.cw IS NOT NULL') .orWhere('note.cw IS NOT NULL')
.orWhere('note.replyId IS NOT NULL') .orWhere('note.replyId IS NOT NULL')
.orWhere('note.hasPoll = false') .orWhere('note.hasPoll = true')
.orWhere('note.fileIds != \'{}\'') .orWhere('note.fileIds != \'{}\'');
.orWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); }))
})); .setParameters({ meId: me.id });
q.setParameters(mutingQuery.getParameters());
} }
// TODO replace allowSilenced with matchingHostQuery
@bindThis @bindThis
public generateBlockedHostQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean, allowSilenced = true): void { public generateBlockedHostQueryForNote<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, excludeAuthor?: boolean, allowSilenced = true): SelectQueryBuilder<E> {
function checkFor(key: 'user' | 'replyUser' | 'renoteUser') { const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => {
q.leftJoin(`note.${key}Instance`, `${key}Instance`);
q.andWhere(new Brackets(qb => { q.andWhere(new Brackets(qb => {
qb.orWhere(`note.${key}Id IS NULL`) // no corresponding user qb.orWhere(`note.${key}Host IS NULL`); // local
.orWhere(`note.${key}Host IS NULL`); // local
if (key !== 'user') {
// note.userId always exists and is non-null
qb.orWhere(`note.${key}Id IS NULL`); // no corresponding user
// note.userId always equals note.userId
if (excludeAuthor) {
qb.orWhere(`note.userId = note.${key}Id`); // author
}
}
if (allowSilenced) { if (allowSilenced) {
qb.orWhere(`${key}Instance.isBlocked = false`); // not blocked // not blocked
this.excludeInstanceWhere(qb, `note.${key}Host`, {
isBlocked: false,
}, 'orWhere');
} else { } else {
qb.orWhere(new Brackets(qbb => qbb // not blocked or silenced
.andWhere(`${key}Instance.isBlocked = false`) // not blocked this.excludeInstanceWhere(qb, `note.${key}Host`, {
.andWhere(`${key}Instance.isSilenced = false`))); // not silenced isBlocked: false,
} isSilenced: false,
}, 'orWhere');
if (excludeAuthor) {
qb.orWhere(`note.userId = note.${key}Id`); // author
} }
})); }));
} };
if (!excludeAuthor) { if (!excludeAuthor) {
checkFor('user'); checkFor('user');
} }
checkFor('replyUser'); checkFor('replyUser');
checkFor('renoteUser'); checkFor('renoteUser');
return q;
}
@bindThis
public generateMatchingHostQueryForNote<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, filters: FindOptionsWhere<MiInstance> | FindOptionsWhere<MiInstance>[], hostProp = 'note.userHost'): SelectQueryBuilder<E> {
return this.includeInstanceWhere(q, hostProp, filters);
}
/**
* Adds condition that hostProp (instance host) matches the given filters.
* The prop should be an expression, not raw values.
*/
@bindThis
public includeInstanceWhere<Q extends WhereExpressionBuilder>(q: Q, hostProp: string, filters: FindOptionsWhere<MiInstance> | FindOptionsWhere<MiInstance>[], join: 'andWhere' | 'orWhere' = 'andWhere'): Q {
const instancesQuery = this.instancesRepository.createQueryBuilder('instance')
.select('1')
.andWhere(`instance.host = ${hostProp}`)
.andWhere(filters);
return q[join](`EXISTS (${instancesQuery.getQuery()})`, instancesQuery.getParameters());
}
/**
* Adds condition that hostProp (instance host) matches the given filters.
* The prop should be an expression, not raw values.
*/
@bindThis
public excludeInstanceWhere<Q extends WhereExpressionBuilder>(q: Q, hostProp: string, filters: FindOptionsWhere<MiInstance> | FindOptionsWhere<MiInstance>[], join: 'andWhere' | 'orWhere' = 'andWhere'): Q {
const instancesQuery = this.instancesRepository.createQueryBuilder('instance')
.select('1')
.andWhere(`instance.host = ${hostProp}`)
.andWhere(filters);
return q[join](`NOT EXISTS (${instancesQuery.getQuery()})`, instancesQuery.getParameters());
}
/**
* Adds condition that followerProp (user ID) is following followeeProp (user ID).
* Both props should be expressions, not raw values.
*/
public addFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('1')
.andWhere(`following.followerId = ${followerProp}`)
.andWhere(`following.followeeId = ${followeeProp}`);
return q.andWhere(`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
};
/**
* Adds condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public excludeBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string): Q {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('1')
.andWhere(`blocking.blockerId = ${blockerProp}`)
.andWhere(`blocking.blockeeId = ${blockeeProp}`);
return q.andWhere(`NOT EXISTS (${blockingQuery.getQuery()})`, blockingQuery.getParameters());
};
/**
* Adds condition that muterProp (user ID) is not muting muteeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public excludeMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('1')
.andWhere(`muting.muterId = ${muterProp}`)
.andWhere(`muting.muteeId = ${muteeProp}`);
if (exclude) {
mutingQuery.andWhere({ muteeId: Not(exclude.id) });
}
return q.andWhere(`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters());
}
/**
* Adds condition that muterProp (user ID) is not muting renotes by muteeProp (user ID).
* Both props should be expressions, not raw values.
*/
public excludeMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
.select('1')
.andWhere(`renote_muting.muterId = ${muterProp}`)
.andWhere(`renote_muting.muteeId = ${muteeProp}`);
return q.andWhere(`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters());
};
/**
* Adds condition that muterProp (user ID) is not muting muteeProp (instance host).
* Both props should be expressions, not raw values.
*/
@bindThis
public excludeMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
.select('1')
.andWhere(`user_profile.userId = ${muterProp}`)
.andWhere(`"user_profile"."mutedInstances"::jsonb ? ${muteeProp}`);
return q.andWhere(`NOT EXISTS (${mutingInstanceQuery.getQuery()})`, mutingInstanceQuery.getParameters());
}
/**
* Adds condition that muterProp (user ID) is not muting muteeProp (note ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public excludeMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
.select('1')
.andWhere(`threadMuted.userId = ${muterProp}`)
.andWhere(`threadMuted.threadId = ${muteeProp}`);
return q.andWhere(`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters());
} }
} }