more QueryService fixes

This commit is contained in:
Hazelnoot 2025-06-03 15:12:59 -04:00
parent 7ab5ce1537
commit 15ebb0ef85

View file

@ -7,10 +7,11 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets, Not, WhereExpressionBuilder } 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, InstancesRepository, MiInstance } from '@/models/_.js'; import { MiInstance } from '@/models/Instance.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta, InstancesRepository } 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, FindOptionsWhere, ObjectLiteral } from 'typeorm'; import type { SelectQueryBuilder, ObjectLiteral } from 'typeorm';
@Injectable() @Injectable()
export class QueryService { export class QueryService {
@ -79,32 +80,31 @@ export class QueryService {
// 投稿の作者にブロックされていない かつ // 投稿の作者にブロックされていない かつ
// 投稿の返信先の作者にブロックされていない かつ // 投稿の返信先の作者にブロックされていない かつ
// 投稿の引用元の作者にブロックされていない // 投稿の引用元の作者にブロックされていない
return this.excludeBlockingUser(q, 'note.userId', ':meId') return this
.andWhere(new Brackets(qb => { .andNotBlockingUser(q, 'note.userId', ':meId')
this.excludeBlockingUser(qb, 'note.replyUserId', ':meId', 'orWhere') .andWhere(new Brackets(qb => this
.orWhere('note.replyUserId IS NULL'); .orNotBlockingUser(qb, 'note.replyUserId', ':meId')
})) .orWhere('note.replyUserId IS NULL')))
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => this
this.excludeBlockingUser(qb, 'note.renoteUserId', ':meId', 'orWhere') .orNotBlockingUser(qb, 'note.renoteUserId', ':meId')
.orWhere('note.renoteUserId IS NULL'); .orWhere('note.renoteUserId IS NULL')))
}))
.setParameters({ meId: me.id }); .setParameters({ meId: me.id });
} }
@bindThis @bindThis
public generateBlockQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> { public generateBlockQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
this.excludeBlockingUser(q, ':meId', 'user.id'); this.andNotBlockingUser(q, ':meId', 'user.id');
this.excludeBlockingUser(q, 'user.id', ':me.id'); this.andNotBlockingUser(q, 'user.id', ':me.id');
return q.setParameters({ meId: me.id }); return q.setParameters({ meId: me.id });
} }
@bindThis @bindThis
public generateMutedNoteThreadQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> { public generateMutedNoteThreadQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
return this.excludeMutingThread(q, ':meId', 'note.id') return this
.andWhere(new Brackets(qb => { .andNotMutingThread(q, ':meId', 'note.id')
this.excludeMutingThread(qb, ':meId', 'note.threadId', 'orWhere') .andWhere(new Brackets(qb => this
.orWhere('note.threadId IS NULL'); .orNotMutingThread(qb, ':meId', 'note.threadId')
})) .orWhere('note.threadId IS NULL')))
.setParameters({ meId: me.id }); .setParameters({ meId: me.id });
} }
@ -113,33 +113,32 @@ export class QueryService {
// 投稿の作者をミュートしていない かつ // 投稿の作者をミュートしていない かつ
// 投稿の返信先の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない // 投稿の引用元の作者をミュートしていない
this.excludeMutingUser(q, ':meId', 'note.userId', 'andWhere', exclude) return this
.andWhere(new Brackets(qb => { .andNotMutingUser(q, ':meId', 'note.userId', exclude)
this.excludeMutingUser(qb, ':meId', 'note.replyUserId', 'orWhere', exclude) .andWhere(new Brackets(qb => this
.orWhere('note.replyUserId IS NULL'); .orNotMutingUser(qb, ':meId', 'note.replyUserId', exclude)
})) .orWhere('note.replyUserId IS NULL')))
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => this
this.excludeMutingUser(qb, ':meId', 'note.renoteUserId', 'orWhere', exclude) .orNotMutingUser(qb, ':meId', 'note.renoteUserId', exclude)
.orWhere('note.renoteUserId IS NULL'); .orWhere('note.renoteUserId IS NULL')))
})); // TODO exclude should also pass a host to skip these instances
// mute instances
// mute instances .andWhere(new Brackets(qb => this
this.excludeMutingInstance(q, ':meId', 'note.userHost', 'andWhere') .andNotMutingInstance(qb, ':meId', 'note.userHost')
.andWhere(new Brackets(qb => { .orWhere('note.userHost IS NULL')))
this.excludeMutingInstance(qb, ':meId', 'note.replyUserHost', 'orWhere') .andWhere(new Brackets(qb => this
.orWhere('note.replyUserHost IS NULL'); .orNotMutingInstance(qb, ':meId', 'note.replyUserHost')
})) .orWhere('note.replyUserHost IS NULL')))
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => this
this.excludeMutingInstance(qb, ':meId', 'note.renoteUserHost', 'orWhere') .orNotMutingInstance(qb, ':meId', 'note.renoteUserHost')
.orWhere('note.renoteUserHost IS NULL'); .orWhere('note.renoteUserHost IS NULL')))
})); .setParameters({ meId: me.id });
return q.setParameters({ meId: me.id });
} }
@bindThis @bindThis
public generateMutedUserQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> { public generateMutedUserQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
return this.excludeMutingUser(q, ':meId', 'user.id') return this
.andNotMutingUser(q, ':meId', 'user.id')
.setParameters({ meId: me.id }); .setParameters({ meId: me.id });
} }
@ -165,11 +164,9 @@ export class QueryService {
// Mentions me // Mentions me
.orWhere(':meId = ANY (note.mentions)') .orWhere(':meId = ANY (note.mentions)')
// Followers-only post // Followers-only post
.orWhere(new Brackets(qb => { .orWhere(new Brackets(qb => this
// または フォロワー宛ての投稿であり、 .andFollowingUser(qb, ':meId', 'note.userId')
this.addFollowingUser(qb, ':meId', 'note.userId') .andWhere('note.visibility = \'followers\'')));
.andWhere('note.visibility = \'followers\'');
}));
q.setParameters({ meId: me.id }); q.setParameters({ meId: me.id });
} }
@ -178,38 +175,43 @@ export class QueryService {
@bindThis @bindThis
public generateMutedUserRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> { public generateMutedUserRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
return q.andWhere(new Brackets(qb => { return q
this.excludeMutingRenote(qb, ':meId', 'note.userId') .andWhere(new Brackets(qb => this
.orNotMutingRenote(qb, ':meId', 'note.userId')
.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 = true') .orWhere('note.hasPoll = true')
.orWhere('note.fileIds != \'{}\''); .orWhere('note.fileIds != \'{}\'')))
}))
.setParameters({ meId: me.id }); .setParameters({ meId: me.id });
} }
@bindThis
public generateExcludedRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>): SelectQueryBuilder<E> {
return q
.andWhere(new Brackets(qb => qb
.orWhere('note.renoteId IS NULL')
.orWhere('note.text IS NOT NULL')
.orWhere('note.cw IS NOT NULL')
.orWhere('note.replyId IS NOT NULL')
.orWhere('note.hasPoll = true')
.orWhere('note.fileIds != \'{}\'')));
}
@bindThis @bindThis
public generateBlockedHostQueryForNote<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, excludeAuthor?: boolean): SelectQueryBuilder<E> { public generateBlockedHostQueryForNote<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, excludeAuthor?: boolean): SelectQueryBuilder<E> {
const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => { const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => this
q.andWhere(new Brackets(qb => { .leftJoinInstance(q, `note.${key}Instance`, `${key}Instance`)
qb.orWhere(`note.${key}Host IS NULL`); // local .andWhere(new Brackets(qb => {
qb
.orWhere(`"${key}Instance" IS NULL`) // local
.orWhere(`"${key}Instance"."isBlocked" = false`); // not blocked
if (key !== 'user') { if (excludeAuthor) {
// note.userId always exists and is non-null qb.orWhere(`note.userId = note.${key}Id`); // author
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
}
} }
// not blocked
this.excludeInstanceWhere(qb, `note.${key}Host`, { isBlocked: false }, 'orWhere');
})); }));
};
if (!excludeAuthor) { if (!excludeAuthor) {
checkFor('user'); checkFor('user');
@ -221,62 +223,58 @@ export class QueryService {
} }
@bindThis @bindThis
public generateSilencedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null, userProp = 'user'): SelectQueryBuilder<E> { public generateSilencedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
if (!me) { if (!me) {
return q.andWhere(`${userProp}.isSilenced = false`); return q.andWhere('user.isSilenced = false');
} }
return q return this
.andWhere(new Brackets(qb => { .leftJoinInstance(q, 'note.userInstance', 'userInstance')
.andWhere(new Brackets(qb => this
// case 1: we are following the user // case 1: we are following the user
this.addFollowingUser(qb, ':meId', `${userProp}.id`, 'orWhere'); .orFollowingUser(qb, ':meId', 'note.userId')
// case 2: user not silenced AND instance not silenced // case 2: user not silenced AND instance not silenced
qb.orWhere(new Brackets(qbb => { .orWhere(new Brackets(qbb => qbb
this.includeInstanceWhere(qbb, `${userProp}.host`, { isSilenced: false }); .andWhere(new Brackets(qbbb => qbbb
qbb.andWhere(`${userProp}.isSilenced = false`); .orWhere('"userInstance"."isSilenced" = false')
})); .orWhere('"userInstance" IS NULL')))
})) .andWhere('user.isSilenced = false')))))
.setParameters({ meId: me.id }); .setParameters({ meId: me.id });
} }
@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. * Left-joins an instance in to the query with a given alias and optional condition.
* The prop should be an expression, not raw values. * These calls are de-duplicated - multiple uses of the same alias are skipped.
*/ */
@bindThis @bindThis
public includeInstanceWhere<Q extends WhereExpressionBuilder>(q: Q, hostProp: string, filters: FindOptionsWhere<MiInstance> | FindOptionsWhere<MiInstance>[], join: 'andWhere' | 'orWhere' = 'andWhere'): Q { public leftJoinInstance<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, relation: string | typeof MiInstance, alias: string, condition?: string): SelectQueryBuilder<E> {
const instancesQuery = this.instancesRepository.createQueryBuilder('instance') // Skip if it's already joined, otherwise we'll get an error
.select('1') if (!q.expressionMap.joinAttributes.some(j => j.alias.name === alias)) {
.andWhere(`instance.host = ${hostProp}`) q.leftJoin(relation, alias, condition);
.andWhere(filters); }
return q[join](`EXISTS (${instancesQuery.getQuery()})`, instancesQuery.getParameters()); return q;
} }
/** /**
* Adds condition that hostProp (instance host) matches the given filters. * Adds OR condition that followerProp (user ID) is following followeeProp (user ID).
* 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. * Both props should be expressions, not raw values.
*/ */
public addFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { @bindThis
public orFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere');
}
/**
* Adds AND condition that followerProp (user ID) is following followeeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public andFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere');
}
private addFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q {
const followingQuery = this.followingsRepository.createQueryBuilder('following') const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('1') .select('1')
.andWhere(`following.followerId = ${followerProp}`) .andWhere(`following.followerId = ${followerProp}`)
@ -286,11 +284,24 @@ export class QueryService {
}; };
/** /**
* Adds condition that blockerProp (user ID) is not blocking blockeeProp (user ID). * Adds OR condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
* Both props should be expressions, not raw values. * Both props should be expressions, not raw values.
*/ */
@bindThis @bindThis
public excludeBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { public orNotBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string): Q {
return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'orWhere');
}
/**
* Adds AND condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public andNotBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string): Q {
return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'andWhere');
}
private excludeBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string, join: 'andWhere' | 'orWhere'): Q {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('1') .select('1')
.andWhere(`blocking.blockerId = ${blockerProp}`) .andWhere(`blocking.blockerId = ${blockerProp}`)
@ -300,11 +311,24 @@ export class QueryService {
}; };
/** /**
* Adds condition that muterProp (user ID) is not muting muteeProp (user ID). * Adds OR condition that muterProp (user ID) is not muting muteeProp (user ID).
* Both props should be expressions, not raw values. * Both props should be expressions, not raw values.
*/ */
@bindThis @bindThis
public excludeMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere', exclude?: { id: MiUser['id'] }): Q { public orNotMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q {
return this.excludeMutingUser(q, muterProp, muteeProp, 'orWhere', exclude);
}
/**
* Adds AND condition that muterProp (user ID) is not muting muteeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public andNotMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q {
return this.excludeMutingUser(q, muterProp, muteeProp, 'andWhere', exclude);
}
private excludeMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere', exclude?: { id: MiUser['id'] }): Q {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('1') .select('1')
.andWhere(`muting.muterId = ${muterProp}`) .andWhere(`muting.muterId = ${muterProp}`)
@ -318,10 +342,24 @@ export class QueryService {
} }
/** /**
* Adds condition that muterProp (user ID) is not muting renotes by muteeProp (user ID). * Adds OR condition that muterProp (user ID) is not muting renotes by muteeProp (user ID).
* Both props should be expressions, not raw values. * Both props should be expressions, not raw values.
*/ */
public excludeMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { @bindThis
public orNotMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingRenote(q, muterProp, muteeProp, 'orWhere');
}
/**
* Adds AND condition that muterProp (user ID) is not muting renotes by muteeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public andNotMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingRenote(q, muterProp, muteeProp, 'andWhere');
}
private excludeMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
.select('1') .select('1')
.andWhere(`renote_muting.muterId = ${muterProp}`) .andWhere(`renote_muting.muterId = ${muterProp}`)
@ -331,11 +369,24 @@ export class QueryService {
}; };
/** /**
* Adds condition that muterProp (user ID) is not muting muteeProp (instance host). * Adds OR condition that muterProp (user ID) is not muting muteeProp (instance host).
* Both props should be expressions, not raw values. * Both props should be expressions, not raw values.
*/ */
@bindThis @bindThis
public excludeMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { public orNotMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingInstance(q, muterProp, muteeProp, 'orWhere');
}
/**
* Adds AND condition that muterProp (user ID) is not muting muteeProp (instance host).
* Both props should be expressions, not raw values.
*/
@bindThis
public andNotMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingInstance(q, muterProp, muteeProp, 'andWhere');
}
private excludeMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
.select('1') .select('1')
.andWhere(`user_profile.userId = ${muterProp}`) .andWhere(`user_profile.userId = ${muterProp}`)
@ -345,11 +396,24 @@ export class QueryService {
} }
/** /**
* Adds condition that muterProp (user ID) is not muting muteeProp (note ID). * Adds OR condition that muterProp (user ID) is not muting muteeProp (note ID).
* Both props should be expressions, not raw values. * Both props should be expressions, not raw values.
*/ */
@bindThis @bindThis
public excludeMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { public orNotMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingThread(q, muterProp, muteeProp, 'orWhere');
}
/**
* Adds AND condition that muterProp (user ID) is not muting muteeProp (note ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public andNotMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingThread(q, muterProp, muteeProp, 'andWhere');
}
private excludeMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
.select('1') .select('1')
.andWhere(`threadMuted.userId = ${muterProp}`) .andWhere(`threadMuted.userId = ${muterProp}`)