Use raw sql for comments

pull/5544/head
Chocobozzz 2023-01-05 15:31:51 +01:00
parent 458685e0d0
commit cde3d90ded
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
9 changed files with 624 additions and 378 deletions

View File

@ -309,7 +309,7 @@ async function videoCommentsController (req: express.Request, res: express.Respo
if (redirectIfNotOwned(video.url, res)) return
const handler = async (start: number, count: number) => {
const result = await VideoCommentModel.listAndCountByVideoForAP(video, start, count)
const result = await VideoCommentModel.listAndCountByVideoForAP({ video, start, count })
return {
total: result.total,

View File

@ -1,4 +1,6 @@
import { MCommentFormattable } from '@server/types/models'
import express from 'express'
import { ResultList, ThreadsResultList, UserRight, VideoCommentCreate } from '../../../../shared/models'
import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
import { VideoCommentThreads } from '../../../../shared/models/videos/comment/video-comment.model'
@ -109,7 +111,7 @@ async function listVideoThreads (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
let resultList: ThreadsResultList<VideoCommentModel>
let resultList: ThreadsResultList<MCommentFormattable>
if (video.commentsEnabled === true) {
const apiOptions = await Hooks.wrapObject({
@ -144,12 +146,11 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
const video = res.locals.onlyVideo
const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
let resultList: ResultList<VideoCommentModel>
let resultList: ResultList<MCommentFormattable>
if (video.commentsEnabled === true) {
const apiOptions = await Hooks.wrapObject({
videoId: video.id,
isVideoOwned: video.isOwned(),
threadId: res.locals.videoCommentThread.id,
user
}, 'filter:api.video-thread-comments.list.params')

View File

@ -1,31 +1,42 @@
import express from 'express'
import { cloneDeep } from 'lodash'
import * as Sequelize from 'sequelize'
import express from 'express'
import { logger } from '@server/helpers/logger'
import { sequelizeTypescript } from '@server/initializers/database'
import { ResultList } from '../../shared/models'
import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model'
import { VideoCommentModel } from '../models/video/video-comment'
import { MAccountDefault, MComment, MCommentOwnerVideo, MCommentOwnerVideoReply, MVideoFullLight } from '../types/models'
import {
MAccountDefault,
MComment,
MCommentFormattable,
MCommentOwnerVideo,
MCommentOwnerVideoReply,
MVideoFullLight
} from '../types/models'
import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
import { getLocalVideoCommentActivityPubUrl } from './activitypub/url'
import { Hooks } from './plugins/hooks'
async function removeComment (videoCommentInstance: MCommentOwnerVideo, req: express.Request, res: express.Response) {
const videoCommentInstanceBefore = cloneDeep(videoCommentInstance)
async function removeComment (commentArg: MComment, req: express.Request, res: express.Response) {
let videoCommentInstanceBefore: MCommentOwnerVideo
await sequelizeTypescript.transaction(async t => {
if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) {
await sendDeleteVideoComment(videoCommentInstance, t)
const comment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(commentArg.url, t)
videoCommentInstanceBefore = cloneDeep(comment)
if (comment.isOwned() || comment.Video.isOwned()) {
await sendDeleteVideoComment(comment, t)
}
videoCommentInstance.markAsDeleted()
comment.markAsDeleted()
await videoCommentInstance.save({ transaction: t })
await comment.save({ transaction: t })
logger.info('Video comment %d deleted.', comment.id)
})
logger.info('Video comment %d deleted.', videoCommentInstance.id)
Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore, req, res })
}
@ -64,7 +75,7 @@ async function createVideoComment (obj: {
return savedComment
}
function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>): VideoCommentThreadTree {
function buildFormattedCommentTree (resultList: ResultList<MCommentFormattable>): VideoCommentThreadTree {
// Comments are sorted by id ASC
const comments = resultList.data

View File

@ -1,7 +1,24 @@
import { isPlainObject } from 'lodash'
import { Model as SequelizeModel, Sequelize } from 'sequelize'
import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize'
import { logger } from '@server/helpers/logger'
/**
*
* Build Sequelize models from sequelize raw query (that must use { nest: true } options)
*
* In order to sequelize to correctly build the JSON this class will ingest,
* the columns selected in the raw query should be in the following form:
* * All tables must be Pascal Cased (for example "VideoChannel")
* * Root table must end with `Model` (for example "VideoCommentModel")
* * Joined tables must contain the origin table name + '->JoinedTable'. For example:
* * "Actor" is joined to "Account": "Actor" table must be renamed "Account->Actor"
* * "Account->Actor" is joined to "Server": "Server" table must be renamed to "Account->Actor->Server"
* * Selected columns must be renamed to contain the JSON path:
* * "videoComment"."id": "VideoCommentModel"."id"
* * "Account"."Actor"."Server"."id": "Account.Actor.Server.id"
* * All tables must contain the row id
*/
export class ModelBuilder <T extends SequelizeModel> {
private readonly modelRegistry = new Map<string, T>()
@ -72,18 +89,18 @@ export class ModelBuilder <T extends SequelizeModel> {
'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
{ existing: this.sequelize.modelManager.all.map(m => m.name) }
)
return undefined
return { created: false, model: null }
}
// FIXME: typings
const model = new (Model as any)(json)
const model = Model.build(json, { raw: true, isNewRecord: false })
this.modelRegistry.set(registryKey, model)
return { created: true, model }
}
private findModelBuilder (modelName: string) {
return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName))
return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) as ModelStatic<T>
}
private buildSequelizeModelName (modelName: string) {

View File

@ -231,12 +231,12 @@ function parseRowCountResult (result: any) {
return 0
}
function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) {
return stringArr.map(t => {
function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) {
return toEscape.map(t => {
return t === null
? null
: sequelize.escape('' + t)
}).join(', ')
}).concat(additionalUnescaped).join(', ')
}
function buildLocalAccountIdsIn () {

View File

@ -0,0 +1,394 @@
import { Model, Sequelize, Transaction } from 'sequelize'
import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
import { createSafeIn, getCommentSort, parseRowCountResult } from '@server/models/utils'
import { ActorImageType, VideoPrivacy } from '@shared/models'
import { VideoCommentTableAttributes } from './video-comment-table-attributes'
export interface ListVideoCommentsOptions {
selectType: 'api' | 'feed' | 'comment-only'
start?: number
count?: number
sort?: string
videoId?: number
threadId?: number
accountId?: number
videoChannelId?: number
blockerAccountIds?: number[]
isThread?: boolean
notDeleted?: boolean
isLocal?: boolean
onLocalVideo?: boolean
onPublicVideo?: boolean
videoAccountOwnerId?: boolean
search?: string
searchAccount?: string
searchVideo?: string
includeReplyCounters?: boolean
transaction?: Transaction
}
export class VideoCommentListQueryBuilder extends AbstractRunQuery {
private readonly tableAttributes = new VideoCommentTableAttributes()
private innerQuery: string
private select = ''
private joins = ''
private innerSelect = ''
private innerJoins = ''
private innerWhere = ''
private readonly built = {
cte: false,
accountJoin: false,
videoJoin: false,
videoChannelJoin: false,
avatarJoin: false
}
constructor (
protected readonly sequelize: Sequelize,
private readonly options: ListVideoCommentsOptions
) {
super(sequelize)
}
async listComments <T extends Model> () {
this.buildListQuery()
const results = await this.runQuery({ nest: true, transaction: this.options.transaction })
const modelBuilder = new ModelBuilder<T>(this.sequelize)
return modelBuilder.createModels(results, 'VideoComment')
}
async countComments () {
this.buildCountQuery()
const result = await this.runQuery({ transaction: this.options.transaction })
return parseRowCountResult(result)
}
// ---------------------------------------------------------------------------
private buildListQuery () {
this.buildInnerListQuery()
this.buildListSelect()
this.query = `${this.select} ` +
`FROM (${this.innerQuery}) AS "VideoCommentModel" ` +
`${this.joins} ` +
`${this.getOrder()} ` +
`${this.getLimit()}`
}
private buildInnerListQuery () {
this.buildWhere()
this.buildInnerListSelect()
this.innerQuery = `${this.innerSelect} ` +
`FROM "videoComment" AS "VideoCommentModel" ` +
`${this.innerJoins} ` +
`${this.innerWhere} ` +
`${this.getOrder()} ` +
`${this.getInnerLimit()}`
}
// ---------------------------------------------------------------------------
private buildCountQuery () {
this.buildWhere()
this.query = `SELECT COUNT(*) AS "total" ` +
`FROM "videoComment" AS "VideoCommentModel" ` +
`${this.innerJoins} ` +
`${this.innerWhere}`
}
// ---------------------------------------------------------------------------
private buildWhere () {
let where: string[] = []
if (this.options.videoId) {
this.replacements.videoId = this.options.videoId
where.push('"VideoCommentModel"."videoId" = :videoId')
}
if (this.options.threadId) {
this.replacements.threadId = this.options.threadId
where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)')
}
if (this.options.accountId) {
this.replacements.accountId = this.options.accountId
where.push('"VideoCommentModel"."accountId" = :accountId')
}
if (this.options.videoChannelId) {
this.buildVideoChannelJoin()
this.replacements.videoChannelId = this.options.videoChannelId
where.push('"Account->VideoChannel"."id" = :videoChannelId')
}
if (this.options.blockerAccountIds) {
this.buildVideoChannelJoin()
where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel'))
}
if (this.options.isThread === true) {
where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL')
}
if (this.options.notDeleted === true) {
where.push('"VideoCommentModel"."deletedAt" IS NULL')
}
if (this.options.isLocal === true) {
this.buildAccountJoin()
where.push('"Account->Actor"."serverId" IS NULL')
} else if (this.options.isLocal === false) {
this.buildAccountJoin()
where.push('"Account->Actor"."serverId" IS NOT NULL')
}
if (this.options.onLocalVideo === true) {
this.buildVideoJoin()
where.push('"Video"."remote" IS FALSE')
} else if (this.options.onLocalVideo === false) {
this.buildVideoJoin()
where.push('"Video"."remote" IS TRUE')
}
if (this.options.onPublicVideo === true) {
this.buildVideoJoin()
where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`)
}
if (this.options.videoAccountOwnerId) {
this.buildVideoChannelJoin()
this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId
where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
}
if (this.options.search) {
this.buildVideoJoin()
this.buildAccountJoin()
const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
where.push(
`(` +
`"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` +
`"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
`"Account"."name" ILIKE ${escapedLikeSearch} OR ` +
`"Video"."name" ILIKE ${escapedLikeSearch} ` +
`)`
)
}
if (this.options.searchAccount) {
this.buildAccountJoin()
const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%')
where.push(
`(` +
`"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
`"Account"."name" ILIKE ${escapedLikeSearch} ` +
`)`
)
}
if (this.options.searchVideo) {
this.buildVideoJoin()
const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%')
where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`)
}
if (where.length !== 0) {
this.innerWhere = `WHERE ${where.join(' AND ')}`
}
}
private buildAccountJoin () {
if (this.built.accountJoin) return
this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' +
'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' +
'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" '
this.built.accountJoin = true
}
private buildVideoJoin () {
if (this.built.videoJoin) return
this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" '
this.built.videoJoin = true
}
private buildVideoChannelJoin () {
if (this.built.videoChannelJoin) return
this.buildVideoJoin()
this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" '
this.built.videoChannelJoin = true
}
private buildAvatarsJoin () {
if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
if (this.built.avatarJoin) return
this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
`ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` +
`AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
this.built.avatarJoin = true
}
// ---------------------------------------------------------------------------
private buildListSelect () {
const toSelect = [ '"VideoCommentModel".*' ]
if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
this.buildAvatarsJoin()
toSelect.push(this.tableAttributes.getAvatarAttributes())
}
if (this.options.includeReplyCounters === true) {
toSelect.push(this.getTotalRepliesSelect())
toSelect.push(this.getAuthorTotalRepliesSelect())
}
this.select = this.buildSelect(toSelect)
}
private buildInnerListSelect () {
let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ]
if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
this.buildAccountJoin()
this.buildVideoJoin()
toSelect = toSelect.concat([
this.tableAttributes.getVideoAttributes(),
this.tableAttributes.getAccountAttributes(),
this.tableAttributes.getActorAttributes(),
this.tableAttributes.getServerAttributes()
])
}
this.innerSelect = this.buildSelect(toSelect)
}
// ---------------------------------------------------------------------------
private getBlockWhere (commentTableName: string, channelTableName: string) {
const where: string[] = []
const blockerIdsString = createSafeIn(
this.sequelize,
this.options.blockerAccountIds,
[ `"${channelTableName}"."accountId"` ]
)
where.push(
`NOT EXISTS (` +
`SELECT 1 FROM "accountBlocklist" ` +
`WHERE "targetAccountId" = "${commentTableName}"."accountId" ` +
`AND "accountId" IN (${blockerIdsString})` +
`)`
)
where.push(
`NOT EXISTS (` +
`SELECT 1 FROM "account" ` +
`INNER JOIN "actor" ON account."actorId" = actor.id ` +
`INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
`WHERE "account"."id" = "${commentTableName}"."accountId" ` +
`AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
`)`
)
return where
}
// ---------------------------------------------------------------------------
private getTotalRepliesSelect () {
const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
return `(` +
`SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
`LEFT JOIN "video" ON "video"."id" = "replies"."videoId" ` +
`LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
`WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
`AND "deletedAt" IS NULL ` +
`AND ${blockWhereString} ` +
`) AS "totalReplies"`
}
private getAuthorTotalRepliesSelect () {
return `(` +
`SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
`INNER JOIN "video" ON "video"."id" = "replies"."videoId" ` +
`INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
`WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` +
`) AS "totalRepliesFromVideoAuthor"`
}
private getOrder () {
if (!this.options.sort) return ''
const orders = getCommentSort(this.options.sort)
return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
}
private getLimit () {
if (!this.options.count) return ''
this.replacements.limit = this.options.count
return `LIMIT :limit `
}
private getInnerLimit () {
if (!this.options.count) return ''
this.replacements.limit = this.options.count
this.replacements.offset = this.options.start || 0
return `LIMIT :limit OFFSET :offset `
}
}

View File

@ -0,0 +1,78 @@
export class VideoCommentTableAttributes {
getVideoCommentAttributes () {
return [
'"VideoCommentModel"."id"',
'"VideoCommentModel"."url"',
'"VideoCommentModel"."deletedAt"',
'"VideoCommentModel"."updatedAt"',
'"VideoCommentModel"."createdAt"',
'"VideoCommentModel"."text"',
'"VideoCommentModel"."originCommentId"',
'"VideoCommentModel"."inReplyToCommentId"',
'"VideoCommentModel"."videoId"',
'"VideoCommentModel"."accountId"'
].join(', ')
}
getAccountAttributes () {
return [
`"Account"."id" AS "Account.id"`,
`"Account"."name" AS "Account.name"`,
`"Account"."description" AS "Account.description"`,
`"Account"."createdAt" AS "Account.createdAt"`,
`"Account"."updatedAt" AS "Account.updatedAt"`,
`"Account"."actorId" AS "Account.actorId"`,
`"Account"."userId" AS "Account.userId"`,
`"Account"."applicationId" AS "Account.applicationId"`
].join(', ')
}
getVideoAttributes () {
return [
`"Video"."id" AS "Video.id"`,
`"Video"."uuid" AS "Video.uuid"`,
`"Video"."name" AS "Video.name"`
].join(', ')
}
getActorAttributes () {
return [
`"Account->Actor"."id" AS "Account.Actor.id"`,
`"Account->Actor"."type" AS "Account.Actor.type"`,
`"Account->Actor"."preferredUsername" AS "Account.Actor.preferredUsername"`,
`"Account->Actor"."url" AS "Account.Actor.url"`,
`"Account->Actor"."followersCount" AS "Account.Actor.followersCount"`,
`"Account->Actor"."followingCount" AS "Account.Actor.followingCount"`,
`"Account->Actor"."remoteCreatedAt" AS "Account.Actor.remoteCreatedAt"`,
`"Account->Actor"."serverId" AS "Account.Actor.serverId"`,
`"Account->Actor"."createdAt" AS "Account.Actor.createdAt"`,
`"Account->Actor"."updatedAt" AS "Account.Actor.updatedAt"`
].join(', ')
}
getServerAttributes () {
return [
`"Account->Actor->Server"."id" AS "Account.Actor.Server.id"`,
`"Account->Actor->Server"."host" AS "Account.Actor.Server.host"`,
`"Account->Actor->Server"."redundancyAllowed" AS "Account.Actor.Server.redundancyAllowed"`,
`"Account->Actor->Server"."createdAt" AS "Account.Actor.Server.createdAt"`,
`"Account->Actor->Server"."updatedAt" AS "Account.Actor.Server.updatedAt"`
].join(', ')
}
getAvatarAttributes () {
return [
`"Account->Actor->Avatars"."id" AS "Account.Actor.Avatars.id"`,
`"Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename"`,
`"Account->Actor->Avatars"."height" AS "Account.Actor.Avatars.height"`,
`"Account->Actor->Avatars"."width" AS "Account.Actor.Avatars.width"`,
`"Account->Actor->Avatars"."fileUrl" AS "Account.Actor.Avatars.fileUrl"`,
`"Account->Actor->Avatars"."onDisk" AS "Account.Actor.Avatars.onDisk"`,
`"Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type"`,
`"Account->Actor->Avatars"."actorId" AS "Account.Actor.Avatars.actorId"`,
`"Account->Actor->Avatars"."createdAt" AS "Account.Actor.Avatars.createdAt"`,
`"Account->Actor->Avatars"."updatedAt" AS "Account.Actor.Avatars.updatedAt"`
].join(', ')
}
}

View File

@ -1,4 +1,4 @@
import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
import {
AllowNull,
BelongsTo,
@ -13,11 +13,9 @@ import {
Table,
UpdatedAt
} from 'sequelize-typescript'
import { exists } from '@server/helpers/custom-validators/misc'
import { getServerActor } from '@server/models/application/application'
import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
import { uniqify } from '@shared/core-utils'
import { VideoPrivacy } from '@shared/models'
import { pick, uniqify } from '@shared/core-utils'
import { AttributesOnly } from '@shared/typescript-utils'
import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
@ -41,61 +39,19 @@ import {
} from '../../types/models/video'
import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
import { AccountModel } from '../account/account'
import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
import {
buildBlockedAccountSQL,
buildBlockedAccountSQLOptimized,
buildLocalAccountIdsIn,
getCommentSort,
searchAttribute,
throwIfNotValid
} from '../utils'
import { ActorModel } from '../actor/actor'
import { buildLocalAccountIdsIn, throwIfNotValid } from '../utils'
import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder'
import { VideoModel } from './video'
import { VideoChannelModel } from './video-channel'
export enum ScopeNames {
WITH_ACCOUNT = 'WITH_ACCOUNT',
WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
WITH_VIDEO = 'WITH_VIDEO',
ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
WITH_VIDEO = 'WITH_VIDEO'
}
@Scopes(() => ({
[ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
return {
attributes: {
include: [
[
Sequelize.literal(
'(' +
'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
'SELECT COUNT("replies"."id") ' +
'FROM "videoComment" AS "replies" ' +
'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
'AND "deletedAt" IS NULL ' +
'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
')'
),
'totalReplies'
],
[
Sequelize.literal(
'(' +
'SELECT COUNT("replies"."id") ' +
'FROM "videoComment" AS "replies" ' +
'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
'AND "replies"."accountId" = "videoChannel"."accountId"' +
')'
),
'totalRepliesFromVideoAuthor'
]
]
}
} as FindOptions
},
[ScopeNames.WITH_ACCOUNT]: {
include: [
{
@ -103,22 +59,6 @@ export enum ScopeNames {
}
]
},
[ScopeNames.WITH_ACCOUNT_FOR_API]: {
include: [
{
model: AccountModel.unscoped(),
include: [
{
attributes: {
exclude: unusedActorAttributesForAPI
},
model: ActorModel, // Default scope includes avatar and server
required: true
}
]
}
]
},
[ScopeNames.WITH_IN_REPLY_TO]: {
include: [
{
@ -319,93 +259,19 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
searchAccount?: string
searchVideo?: string
}) {
const { start, count, sort, isLocal, search, searchAccount, searchVideo, onLocalVideo } = parameters
const queryOptions: ListVideoCommentsOptions = {
...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
const where: WhereOptions = {
deletedAt: null
}
const whereAccount: WhereOptions = {}
const whereActor: WhereOptions = {}
const whereVideo: WhereOptions = {}
if (isLocal === true) {
Object.assign(whereActor, {
serverId: null
})
} else if (isLocal === false) {
Object.assign(whereActor, {
serverId: {
[Op.ne]: null
}
})
}
if (search) {
Object.assign(where, {
[Op.or]: [
searchAttribute(search, 'text'),
searchAttribute(search, '$Account.Actor.preferredUsername$'),
searchAttribute(search, '$Account.name$'),
searchAttribute(search, '$Video.name$')
]
})
}
if (searchAccount) {
Object.assign(whereActor, {
[Op.or]: [
searchAttribute(searchAccount, '$Account.Actor.preferredUsername$'),
searchAttribute(searchAccount, '$Account.name$')
]
})
}
if (searchVideo) {
Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
}
if (exists(onLocalVideo)) {
Object.assign(whereVideo, { remote: !onLocalVideo })
}
const getQuery = (forCount: boolean) => {
return {
offset: start,
limit: count,
order: getCommentSort(sort),
where,
include: [
{
model: AccountModel.unscoped(),
required: true,
where: whereAccount,
include: [
{
attributes: {
exclude: unusedActorAttributesForAPI
},
model: forCount === true
? ActorModel.unscoped() // Default scope includes avatar and server
: ActorModel,
required: true,
where: whereActor
}
]
},
{
model: VideoModel.unscoped(),
required: true,
where: whereVideo
}
]
}
selectType: 'api',
notDeleted: true
}
return Promise.all([
VideoCommentModel.count(getQuery(true)),
VideoCommentModel.findAll(getQuery(false))
]).then(([ total, data ]) => ({ total, data }))
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
]).then(([ rows, count ]) => {
return { total: count, data: rows }
})
}
static async listThreadsForApi (parameters: {
@ -416,67 +282,40 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
sort: string
user?: MUserAccountId
}) {
const { videoId, isVideoOwned, start, count, sort, user } = parameters
const { videoId, user } = parameters
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
const accountBlockedWhere = {
accountId: {
[Op.notIn]: Sequelize.literal(
'(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
)
}
const commonOptions: ListVideoCommentsOptions = {
selectType: 'api',
videoId,
blockerAccountIds
}
const queryList = {
offset: start,
limit: count,
order: getCommentSort(sort),
where: {
[Op.and]: [
{
videoId
},
{
inReplyToCommentId: null
},
{
[Op.or]: [
accountBlockedWhere,
{
accountId: null
}
]
}
]
}
const listOptions: ListVideoCommentsOptions = {
...commonOptions,
...pick(parameters, [ 'sort', 'start', 'count' ]),
isThread: true,
includeReplyCounters: true
}
const findScopesList: (string | ScopeOptions)[] = [
ScopeNames.WITH_ACCOUNT_FOR_API,
{
method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
}
]
const countOptions: ListVideoCommentsOptions = {
...commonOptions,
const countScopesList: ScopeOptions[] = [
{
method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
}
]
isThread: true
}
const notDeletedQueryCount = {
where: {
videoId,
deletedAt: null,
...accountBlockedWhere
}
const notDeletedCountOptions: ListVideoCommentsOptions = {
...commonOptions,
notDeleted: true
}
return Promise.all([
VideoCommentModel.scope(findScopesList).findAll(queryList),
VideoCommentModel.scope(countScopesList).count(queryList),
VideoCommentModel.count(notDeletedQueryCount)
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
]).then(([ rows, count, totalNotDeletedComments ]) => {
return { total: count, data: rows, totalNotDeletedComments }
})
@ -484,54 +323,29 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
static async listThreadCommentsForApi (parameters: {
videoId: number
isVideoOwned: boolean
threadId: number
user?: MUserAccountId
}) {
const { videoId, threadId, user, isVideoOwned } = parameters
const { user } = parameters
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
const query = {
order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
where: {
videoId,
[Op.and]: [
{
[Op.or]: [
{ id: threadId },
{ originCommentId: threadId }
]
},
{
[Op.or]: [
{
accountId: {
[Op.notIn]: Sequelize.literal(
'(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
)
}
},
{
accountId: null
}
]
}
]
}
const queryOptions: ListVideoCommentsOptions = {
...pick(parameters, [ 'videoId', 'threadId' ]),
selectType: 'api',
sort: 'createdAt',
blockerAccountIds,
includeReplyCounters: true
}
const scopes: any[] = [
ScopeNames.WITH_ACCOUNT_FOR_API,
{
method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
}
]
return Promise.all([
VideoCommentModel.count(query),
VideoCommentModel.scope(scopes).findAll(query)
]).then(([ total, data ]) => ({ total, data }))
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
]).then(([ rows, count ]) => {
return { total: count, data: rows }
})
}
static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
@ -559,31 +373,31 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
.findAll(query)
}
static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) {
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({
videoId: video.id,
isVideoOwned: video.isOwned()
})
static async listAndCountByVideoForAP (parameters: {
video: MVideoImmutable
start: number
count: number
}) {
const { video } = parameters
const query = {
order: [ [ 'createdAt', 'ASC' ] ] as Order,
offset: start,
limit: count,
where: {
videoId: video.id,
accountId: {
[Op.notIn]: Sequelize.literal(
'(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
)
}
},
transaction: t
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
const queryOptions: ListVideoCommentsOptions = {
...pick(parameters, [ 'start', 'count' ]),
selectType: 'comment-only',
videoId: video.id,
sort: 'createdAt',
blockerAccountIds
}
return Promise.all([
VideoCommentModel.count(query),
VideoCommentModel.findAll<MComment>(query)
]).then(([ total, data ]) => ({ total, data }))
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
]).then(([ rows, count ]) => {
return { total: count, data: rows }
})
}
static async listForFeed (parameters: {
@ -592,97 +406,36 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
videoId?: number
accountId?: number
videoChannelId?: number
}): Promise<MCommentOwnerVideoFeed[]> {
const serverActor = await getServerActor()
const { start, count, videoId, accountId, videoChannelId } = parameters
}) {
const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized(
'"VideoCommentModel"."accountId"',
[ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]
)
const queryOptions: ListVideoCommentsOptions = {
...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
if (accountId) {
whereAnd.push({
accountId
})
selectType: 'feed',
sort: '-createdAt',
onPublicVideo: true,
notDeleted: true,
blockerAccountIds
}
const accountWhere = {
[Op.and]: whereAnd
}
const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined
const query = {
order: [ [ 'createdAt', 'DESC' ] ] as Order,
offset: start,
limit: count,
where: {
deletedAt: null,
accountId: accountWhere
},
include: [
{
attributes: [ 'name', 'uuid' ],
model: VideoModel.unscoped(),
required: true,
where: {
privacy: VideoPrivacy.PUBLIC
},
include: [
{
attributes: [ 'accountId' ],
model: VideoChannelModel.unscoped(),
required: true,
where: videoChannelWhere
}
]
}
]
}
if (videoId) query.where['videoId'] = videoId
return VideoCommentModel
.scope([ ScopeNames.WITH_ACCOUNT ])
.findAll(query)
return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
}
static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
const accountWhere = filter.onVideosOfAccount
? { id: filter.onVideosOfAccount.id }
: {}
const queryOptions: ListVideoCommentsOptions = {
selectType: 'comment-only',
const query = {
limit: 1000,
where: {
deletedAt: null,
accountId: ofAccount.id
},
include: [
{
model: VideoModel,
required: true,
include: [
{
model: VideoChannelModel,
required: true,
include: [
{
model: AccountModel,
required: true,
where: accountWhere
}
]
}
]
}
]
accountId: ofAccount.id,
videoAccountOwnerId: filter.onVideosOfAccount?.id,
notDeleted: true,
count: 5000
}
return VideoCommentModel
.scope([ ScopeNames.WITH_ACCOUNT ])
.findAll(query)
return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
}
static async getStats () {
@ -750,9 +503,7 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
}
isOwned () {
if (!this.Account) {
return false
}
if (!this.Account) return false
return this.Account.isOwned()
}
@ -906,22 +657,15 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
}
private static async buildBlockerAccountIds (options: {
videoId: number
isVideoOwned: boolean
user?: MUserAccountId
}) {
const { videoId, user, isVideoOwned } = options
user: MUserAccountId
}): Promise<number[]> {
const { user } = options
const serverActor = await getServerActor()
const blockerAccountIds = [ serverActor.Account.id ]
if (user) blockerAccountIds.push(user.Account.id)
if (isVideoOwned) {
const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
if (videoOwnerAccount) blockerAccountIds.push(videoOwnerAccount.id)
}
return blockerAccountIds
}
}

View File

@ -232,7 +232,8 @@ describe('Test video comments', function () {
await command.addReply({ videoId, toCommentId: threadId2, text: text3 })
const tree = await command.getThread({ videoId: videoUUID, threadId: threadId2 })
expect(tree.comment.totalReplies).to.equal(tree.comment.totalRepliesFromVideoAuthor + 1)
expect(tree.comment.totalRepliesFromVideoAuthor).to.equal(1)
expect(tree.comment.totalReplies).to.equal(2)
})
})