aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/models
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2023-01-05 15:31:51 +0100
committerChocobozzz <me@florianbigard.com>2023-01-09 09:29:02 +0100
commitcde3d90ded5debb24281a444eabb720b721e5600 (patch)
tree9ad93c2228b980863d06fea45e3c0b04003ede2a /server/models
parent458685e0d039a0ad3fa4f26d99746f7d6d0b40e9 (diff)
downloadPeerTube-cde3d90ded5debb24281a444eabb720b721e5600.tar.gz
PeerTube-cde3d90ded5debb24281a444eabb720b721e5600.tar.zst
PeerTube-cde3d90ded5debb24281a444eabb720b721e5600.zip
Use raw sql for comments
Diffstat (limited to 'server/models')
-rw-r--r--server/models/shared/model-builder.ts27
-rw-r--r--server/models/utils.ts6
-rw-r--r--server/models/video/sql/comment/video-comment-list-query-builder.ts394
-rw-r--r--server/models/video/sql/comment/video-comment-table-attributes.ts78
-rw-r--r--server/models/video/video-comment.ts446
5 files changed, 592 insertions, 359 deletions
diff --git a/server/models/shared/model-builder.ts b/server/models/shared/model-builder.ts
index c015ca4f5..07f7c4038 100644
--- a/server/models/shared/model-builder.ts
+++ b/server/models/shared/model-builder.ts
@@ -1,7 +1,24 @@
1import { isPlainObject } from 'lodash' 1import { isPlainObject } from 'lodash'
2import { Model as SequelizeModel, Sequelize } from 'sequelize' 2import { Model as SequelizeModel, ModelStatic, Sequelize } from 'sequelize'
3import { logger } from '@server/helpers/logger' 3import { logger } from '@server/helpers/logger'
4 4
5/**
6 *
7 * Build Sequelize models from sequelize raw query (that must use { nest: true } options)
8 *
9 * In order to sequelize to correctly build the JSON this class will ingest,
10 * the columns selected in the raw query should be in the following form:
11 * * All tables must be Pascal Cased (for example "VideoChannel")
12 * * Root table must end with `Model` (for example "VideoCommentModel")
13 * * Joined tables must contain the origin table name + '->JoinedTable'. For example:
14 * * "Actor" is joined to "Account": "Actor" table must be renamed "Account->Actor"
15 * * "Account->Actor" is joined to "Server": "Server" table must be renamed to "Account->Actor->Server"
16 * * Selected columns must be renamed to contain the JSON path:
17 * * "videoComment"."id": "VideoCommentModel"."id"
18 * * "Account"."Actor"."Server"."id": "Account.Actor.Server.id"
19 * * All tables must contain the row id
20 */
21
5export class ModelBuilder <T extends SequelizeModel> { 22export class ModelBuilder <T extends SequelizeModel> {
6 private readonly modelRegistry = new Map<string, T>() 23 private readonly modelRegistry = new Map<string, T>()
7 24
@@ -72,18 +89,18 @@ export class ModelBuilder <T extends SequelizeModel> {
72 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName), 89 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName),
73 { existing: this.sequelize.modelManager.all.map(m => m.name) } 90 { existing: this.sequelize.modelManager.all.map(m => m.name) }
74 ) 91 )
75 return undefined 92 return { created: false, model: null }
76 } 93 }
77 94
78 // FIXME: typings 95 const model = Model.build(json, { raw: true, isNewRecord: false })
79 const model = new (Model as any)(json) 96
80 this.modelRegistry.set(registryKey, model) 97 this.modelRegistry.set(registryKey, model)
81 98
82 return { created: true, model } 99 return { created: true, model }
83 } 100 }
84 101
85 private findModelBuilder (modelName: string) { 102 private findModelBuilder (modelName: string) {
86 return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) 103 return this.sequelize.modelManager.getModel(this.buildSequelizeModelName(modelName)) as ModelStatic<T>
87 } 104 }
88 105
89 private buildSequelizeModelName (modelName: string) { 106 private buildSequelizeModelName (modelName: string) {
diff --git a/server/models/utils.ts b/server/models/utils.ts
index 3476799ce..0b6ac8340 100644
--- a/server/models/utils.ts
+++ b/server/models/utils.ts
@@ -231,12 +231,12 @@ function parseRowCountResult (result: any) {
231 return 0 231 return 0
232} 232}
233 233
234function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) { 234function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) {
235 return stringArr.map(t => { 235 return toEscape.map(t => {
236 return t === null 236 return t === null
237 ? null 237 ? null
238 : sequelize.escape('' + t) 238 : sequelize.escape('' + t)
239 }).join(', ') 239 }).concat(additionalUnescaped).join(', ')
240} 240}
241 241
242function buildLocalAccountIdsIn () { 242function buildLocalAccountIdsIn () {
diff --git a/server/models/video/sql/comment/video-comment-list-query-builder.ts b/server/models/video/sql/comment/video-comment-list-query-builder.ts
new file mode 100644
index 000000000..f3f6910e1
--- /dev/null
+++ b/server/models/video/sql/comment/video-comment-list-query-builder.ts
@@ -0,0 +1,394 @@
1import { Model, Sequelize, Transaction } from 'sequelize'
2import { AbstractRunQuery, ModelBuilder } from '@server/models/shared'
3import { createSafeIn, getCommentSort, parseRowCountResult } from '@server/models/utils'
4import { ActorImageType, VideoPrivacy } from '@shared/models'
5import { VideoCommentTableAttributes } from './video-comment-table-attributes'
6
7export interface ListVideoCommentsOptions {
8 selectType: 'api' | 'feed' | 'comment-only'
9
10 start?: number
11 count?: number
12 sort?: string
13
14 videoId?: number
15 threadId?: number
16 accountId?: number
17 videoChannelId?: number
18
19 blockerAccountIds?: number[]
20
21 isThread?: boolean
22 notDeleted?: boolean
23 isLocal?: boolean
24 onLocalVideo?: boolean
25 onPublicVideo?: boolean
26 videoAccountOwnerId?: boolean
27
28 search?: string
29 searchAccount?: string
30 searchVideo?: string
31
32 includeReplyCounters?: boolean
33
34 transaction?: Transaction
35}
36
37export class VideoCommentListQueryBuilder extends AbstractRunQuery {
38 private readonly tableAttributes = new VideoCommentTableAttributes()
39
40 private innerQuery: string
41
42 private select = ''
43 private joins = ''
44
45 private innerSelect = ''
46 private innerJoins = ''
47 private innerWhere = ''
48
49 private readonly built = {
50 cte: false,
51 accountJoin: false,
52 videoJoin: false,
53 videoChannelJoin: false,
54 avatarJoin: false
55 }
56
57 constructor (
58 protected readonly sequelize: Sequelize,
59 private readonly options: ListVideoCommentsOptions
60 ) {
61 super(sequelize)
62 }
63
64 async listComments <T extends Model> () {
65 this.buildListQuery()
66
67 const results = await this.runQuery({ nest: true, transaction: this.options.transaction })
68 const modelBuilder = new ModelBuilder<T>(this.sequelize)
69
70 return modelBuilder.createModels(results, 'VideoComment')
71 }
72
73 async countComments () {
74 this.buildCountQuery()
75
76 const result = await this.runQuery({ transaction: this.options.transaction })
77
78 return parseRowCountResult(result)
79 }
80
81 // ---------------------------------------------------------------------------
82
83 private buildListQuery () {
84 this.buildInnerListQuery()
85 this.buildListSelect()
86
87 this.query = `${this.select} ` +
88 `FROM (${this.innerQuery}) AS "VideoCommentModel" ` +
89 `${this.joins} ` +
90 `${this.getOrder()} ` +
91 `${this.getLimit()}`
92 }
93
94 private buildInnerListQuery () {
95 this.buildWhere()
96 this.buildInnerListSelect()
97
98 this.innerQuery = `${this.innerSelect} ` +
99 `FROM "videoComment" AS "VideoCommentModel" ` +
100 `${this.innerJoins} ` +
101 `${this.innerWhere} ` +
102 `${this.getOrder()} ` +
103 `${this.getInnerLimit()}`
104 }
105
106 // ---------------------------------------------------------------------------
107
108 private buildCountQuery () {
109 this.buildWhere()
110
111 this.query = `SELECT COUNT(*) AS "total" ` +
112 `FROM "videoComment" AS "VideoCommentModel" ` +
113 `${this.innerJoins} ` +
114 `${this.innerWhere}`
115 }
116
117 // ---------------------------------------------------------------------------
118
119 private buildWhere () {
120 let where: string[] = []
121
122 if (this.options.videoId) {
123 this.replacements.videoId = this.options.videoId
124
125 where.push('"VideoCommentModel"."videoId" = :videoId')
126 }
127
128 if (this.options.threadId) {
129 this.replacements.threadId = this.options.threadId
130
131 where.push('("VideoCommentModel"."id" = :threadId OR "VideoCommentModel"."originCommentId" = :threadId)')
132 }
133
134 if (this.options.accountId) {
135 this.replacements.accountId = this.options.accountId
136
137 where.push('"VideoCommentModel"."accountId" = :accountId')
138 }
139
140 if (this.options.videoChannelId) {
141 this.buildVideoChannelJoin()
142
143 this.replacements.videoChannelId = this.options.videoChannelId
144
145 where.push('"Account->VideoChannel"."id" = :videoChannelId')
146 }
147
148 if (this.options.blockerAccountIds) {
149 this.buildVideoChannelJoin()
150
151 where = where.concat(this.getBlockWhere('VideoCommentModel', 'Video->VideoChannel'))
152 }
153
154 if (this.options.isThread === true) {
155 where.push('"VideoCommentModel"."inReplyToCommentId" IS NULL')
156 }
157
158 if (this.options.notDeleted === true) {
159 where.push('"VideoCommentModel"."deletedAt" IS NULL')
160 }
161
162 if (this.options.isLocal === true) {
163 this.buildAccountJoin()
164
165 where.push('"Account->Actor"."serverId" IS NULL')
166 } else if (this.options.isLocal === false) {
167 this.buildAccountJoin()
168
169 where.push('"Account->Actor"."serverId" IS NOT NULL')
170 }
171
172 if (this.options.onLocalVideo === true) {
173 this.buildVideoJoin()
174
175 where.push('"Video"."remote" IS FALSE')
176 } else if (this.options.onLocalVideo === false) {
177 this.buildVideoJoin()
178
179 where.push('"Video"."remote" IS TRUE')
180 }
181
182 if (this.options.onPublicVideo === true) {
183 this.buildVideoJoin()
184
185 where.push(`"Video"."privacy" = ${VideoPrivacy.PUBLIC}`)
186 }
187
188 if (this.options.videoAccountOwnerId) {
189 this.buildVideoChannelJoin()
190
191 this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId
192
193 where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`)
194 }
195
196 if (this.options.search) {
197 this.buildVideoJoin()
198 this.buildAccountJoin()
199
200 const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%')
201
202 where.push(
203 `(` +
204 `"VideoCommentModel"."text" ILIKE ${escapedLikeSearch} OR ` +
205 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
206 `"Account"."name" ILIKE ${escapedLikeSearch} OR ` +
207 `"Video"."name" ILIKE ${escapedLikeSearch} ` +
208 `)`
209 )
210 }
211
212 if (this.options.searchAccount) {
213 this.buildAccountJoin()
214
215 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchAccount + '%')
216
217 where.push(
218 `(` +
219 `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` +
220 `"Account"."name" ILIKE ${escapedLikeSearch} ` +
221 `)`
222 )
223 }
224
225 if (this.options.searchVideo) {
226 this.buildVideoJoin()
227
228 const escapedLikeSearch = this.sequelize.escape('%' + this.options.searchVideo + '%')
229
230 where.push(`"Video"."name" ILIKE ${escapedLikeSearch}`)
231 }
232
233 if (where.length !== 0) {
234 this.innerWhere = `WHERE ${where.join(' AND ')}`
235 }
236 }
237
238 private buildAccountJoin () {
239 if (this.built.accountJoin) return
240
241 this.innerJoins += ' LEFT JOIN "account" "Account" ON "Account"."id" = "VideoCommentModel"."accountId" ' +
242 'LEFT JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' +
243 'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" '
244
245 this.built.accountJoin = true
246 }
247
248 private buildVideoJoin () {
249 if (this.built.videoJoin) return
250
251 this.innerJoins += ' LEFT JOIN "video" "Video" ON "Video"."id" = "VideoCommentModel"."videoId" '
252
253 this.built.videoJoin = true
254 }
255
256 private buildVideoChannelJoin () {
257 if (this.built.videoChannelJoin) return
258
259 this.buildVideoJoin()
260
261 this.innerJoins += ' LEFT JOIN "videoChannel" "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" '
262
263 this.built.videoChannelJoin = true
264 }
265
266 private buildAvatarsJoin () {
267 if (this.options.selectType !== 'api' && this.options.selectType !== 'feed') return ''
268 if (this.built.avatarJoin) return
269
270 this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` +
271 `ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` +
272 `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}`
273
274 this.built.avatarJoin = true
275 }
276
277 // ---------------------------------------------------------------------------
278
279 private buildListSelect () {
280 const toSelect = [ '"VideoCommentModel".*' ]
281
282 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
283 this.buildAvatarsJoin()
284
285 toSelect.push(this.tableAttributes.getAvatarAttributes())
286 }
287
288 if (this.options.includeReplyCounters === true) {
289 toSelect.push(this.getTotalRepliesSelect())
290 toSelect.push(this.getAuthorTotalRepliesSelect())
291 }
292
293 this.select = this.buildSelect(toSelect)
294 }
295
296 private buildInnerListSelect () {
297 let toSelect = [ this.tableAttributes.getVideoCommentAttributes() ]
298
299 if (this.options.selectType === 'api' || this.options.selectType === 'feed') {
300 this.buildAccountJoin()
301 this.buildVideoJoin()
302
303 toSelect = toSelect.concat([
304 this.tableAttributes.getVideoAttributes(),
305 this.tableAttributes.getAccountAttributes(),
306 this.tableAttributes.getActorAttributes(),
307 this.tableAttributes.getServerAttributes()
308 ])
309 }
310
311 this.innerSelect = this.buildSelect(toSelect)
312 }
313
314 // ---------------------------------------------------------------------------
315
316 private getBlockWhere (commentTableName: string, channelTableName: string) {
317 const where: string[] = []
318
319 const blockerIdsString = createSafeIn(
320 this.sequelize,
321 this.options.blockerAccountIds,
322 [ `"${channelTableName}"."accountId"` ]
323 )
324
325 where.push(
326 `NOT EXISTS (` +
327 `SELECT 1 FROM "accountBlocklist" ` +
328 `WHERE "targetAccountId" = "${commentTableName}"."accountId" ` +
329 `AND "accountId" IN (${blockerIdsString})` +
330 `)`
331 )
332
333 where.push(
334 `NOT EXISTS (` +
335 `SELECT 1 FROM "account" ` +
336 `INNER JOIN "actor" ON account."actorId" = actor.id ` +
337 `INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` +
338 `WHERE "account"."id" = "${commentTableName}"."accountId" ` +
339 `AND "serverBlocklist"."accountId" IN (${blockerIdsString})` +
340 `)`
341 )
342
343 return where
344 }
345
346 // ---------------------------------------------------------------------------
347
348 private getTotalRepliesSelect () {
349 const blockWhereString = this.getBlockWhere('replies', 'videoChannel').join(' AND ')
350
351 return `(` +
352 `SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
353 `LEFT JOIN "video" ON "video"."id" = "replies"."videoId" ` +
354 `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` +
355 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ` +
356 `AND "deletedAt" IS NULL ` +
357 `AND ${blockWhereString} ` +
358 `) AS "totalReplies"`
359 }
360
361 private getAuthorTotalRepliesSelect () {
362 return `(` +
363 `SELECT COUNT("replies"."id") FROM "videoComment" AS "replies" ` +
364 `INNER JOIN "video" ON "video"."id" = "replies"."videoId" ` +
365 `INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` +
366 `WHERE "replies"."originCommentId" = "VideoCommentModel"."id" AND "replies"."accountId" = "videoChannel"."accountId"` +
367 `) AS "totalRepliesFromVideoAuthor"`
368 }
369
370 private getOrder () {
371 if (!this.options.sort) return ''
372
373 const orders = getCommentSort(this.options.sort)
374
375 return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ')
376 }
377
378 private getLimit () {
379 if (!this.options.count) return ''
380
381 this.replacements.limit = this.options.count
382
383 return `LIMIT :limit `
384 }
385
386 private getInnerLimit () {
387 if (!this.options.count) return ''
388
389 this.replacements.limit = this.options.count
390 this.replacements.offset = this.options.start || 0
391
392 return `LIMIT :limit OFFSET :offset `
393 }
394}
diff --git a/server/models/video/sql/comment/video-comment-table-attributes.ts b/server/models/video/sql/comment/video-comment-table-attributes.ts
new file mode 100644
index 000000000..cae3c1683
--- /dev/null
+++ b/server/models/video/sql/comment/video-comment-table-attributes.ts
@@ -0,0 +1,78 @@
1export class VideoCommentTableAttributes {
2
3 getVideoCommentAttributes () {
4 return [
5 '"VideoCommentModel"."id"',
6 '"VideoCommentModel"."url"',
7 '"VideoCommentModel"."deletedAt"',
8 '"VideoCommentModel"."updatedAt"',
9 '"VideoCommentModel"."createdAt"',
10 '"VideoCommentModel"."text"',
11 '"VideoCommentModel"."originCommentId"',
12 '"VideoCommentModel"."inReplyToCommentId"',
13 '"VideoCommentModel"."videoId"',
14 '"VideoCommentModel"."accountId"'
15 ].join(', ')
16 }
17
18 getAccountAttributes () {
19 return [
20 `"Account"."id" AS "Account.id"`,
21 `"Account"."name" AS "Account.name"`,
22 `"Account"."description" AS "Account.description"`,
23 `"Account"."createdAt" AS "Account.createdAt"`,
24 `"Account"."updatedAt" AS "Account.updatedAt"`,
25 `"Account"."actorId" AS "Account.actorId"`,
26 `"Account"."userId" AS "Account.userId"`,
27 `"Account"."applicationId" AS "Account.applicationId"`
28 ].join(', ')
29 }
30
31 getVideoAttributes () {
32 return [
33 `"Video"."id" AS "Video.id"`,
34 `"Video"."uuid" AS "Video.uuid"`,
35 `"Video"."name" AS "Video.name"`
36 ].join(', ')
37 }
38
39 getActorAttributes () {
40 return [
41 `"Account->Actor"."id" AS "Account.Actor.id"`,
42 `"Account->Actor"."type" AS "Account.Actor.type"`,
43 `"Account->Actor"."preferredUsername" AS "Account.Actor.preferredUsername"`,
44 `"Account->Actor"."url" AS "Account.Actor.url"`,
45 `"Account->Actor"."followersCount" AS "Account.Actor.followersCount"`,
46 `"Account->Actor"."followingCount" AS "Account.Actor.followingCount"`,
47 `"Account->Actor"."remoteCreatedAt" AS "Account.Actor.remoteCreatedAt"`,
48 `"Account->Actor"."serverId" AS "Account.Actor.serverId"`,
49 `"Account->Actor"."createdAt" AS "Account.Actor.createdAt"`,
50 `"Account->Actor"."updatedAt" AS "Account.Actor.updatedAt"`
51 ].join(', ')
52 }
53
54 getServerAttributes () {
55 return [
56 `"Account->Actor->Server"."id" AS "Account.Actor.Server.id"`,
57 `"Account->Actor->Server"."host" AS "Account.Actor.Server.host"`,
58 `"Account->Actor->Server"."redundancyAllowed" AS "Account.Actor.Server.redundancyAllowed"`,
59 `"Account->Actor->Server"."createdAt" AS "Account.Actor.Server.createdAt"`,
60 `"Account->Actor->Server"."updatedAt" AS "Account.Actor.Server.updatedAt"`
61 ].join(', ')
62 }
63
64 getAvatarAttributes () {
65 return [
66 `"Account->Actor->Avatars"."id" AS "Account.Actor.Avatars.id"`,
67 `"Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename"`,
68 `"Account->Actor->Avatars"."height" AS "Account.Actor.Avatars.height"`,
69 `"Account->Actor->Avatars"."width" AS "Account.Actor.Avatars.width"`,
70 `"Account->Actor->Avatars"."fileUrl" AS "Account.Actor.Avatars.fileUrl"`,
71 `"Account->Actor->Avatars"."onDisk" AS "Account.Actor.Avatars.onDisk"`,
72 `"Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type"`,
73 `"Account->Actor->Avatars"."actorId" AS "Account.Actor.Avatars.actorId"`,
74 `"Account->Actor->Avatars"."createdAt" AS "Account.Actor.Avatars.createdAt"`,
75 `"Account->Actor->Avatars"."updatedAt" AS "Account.Actor.Avatars.updatedAt"`
76 ].join(', ')
77 }
78}
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index af9614d30..fb9d15e55 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,4 +1,4 @@
1import { FindOptions, Op, Order, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' 1import { FindOptions, Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
2import { 2import {
3 AllowNull, 3 AllowNull,
4 BelongsTo, 4 BelongsTo,
@@ -13,11 +13,9 @@ import {
13 Table, 13 Table,
14 UpdatedAt 14 UpdatedAt
15} from 'sequelize-typescript' 15} from 'sequelize-typescript'
16import { exists } from '@server/helpers/custom-validators/misc'
17import { getServerActor } from '@server/models/application/application' 16import { getServerActor } from '@server/models/application/application'
18import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' 17import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
19import { uniqify } from '@shared/core-utils' 18import { pick, uniqify } from '@shared/core-utils'
20import { VideoPrivacy } from '@shared/models'
21import { AttributesOnly } from '@shared/typescript-utils' 19import { AttributesOnly } from '@shared/typescript-utils'
22import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' 20import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
23import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 21import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
@@ -41,61 +39,19 @@ import {
41} from '../../types/models/video' 39} from '../../types/models/video'
42import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' 40import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
43import { AccountModel } from '../account/account' 41import { AccountModel } from '../account/account'
44import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor' 42import { ActorModel } from '../actor/actor'
45import { 43import { buildLocalAccountIdsIn, throwIfNotValid } from '../utils'
46 buildBlockedAccountSQL, 44import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder'
47 buildBlockedAccountSQLOptimized,
48 buildLocalAccountIdsIn,
49 getCommentSort,
50 searchAttribute,
51 throwIfNotValid
52} from '../utils'
53import { VideoModel } from './video' 45import { VideoModel } from './video'
54import { VideoChannelModel } from './video-channel' 46import { VideoChannelModel } from './video-channel'
55 47
56export enum ScopeNames { 48export enum ScopeNames {
57 WITH_ACCOUNT = 'WITH_ACCOUNT', 49 WITH_ACCOUNT = 'WITH_ACCOUNT',
58 WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
59 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO', 50 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
60 WITH_VIDEO = 'WITH_VIDEO', 51 WITH_VIDEO = 'WITH_VIDEO'
61 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
62} 52}
63 53
64@Scopes(() => ({ 54@Scopes(() => ({
65 [ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
66 return {
67 attributes: {
68 include: [
69 [
70 Sequelize.literal(
71 '(' +
72 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
73 'SELECT COUNT("replies"."id") ' +
74 'FROM "videoComment" AS "replies" ' +
75 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
76 'AND "deletedAt" IS NULL ' +
77 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
78 ')'
79 ),
80 'totalReplies'
81 ],
82 [
83 Sequelize.literal(
84 '(' +
85 'SELECT COUNT("replies"."id") ' +
86 'FROM "videoComment" AS "replies" ' +
87 'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
88 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
89 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
90 'AND "replies"."accountId" = "videoChannel"."accountId"' +
91 ')'
92 ),
93 'totalRepliesFromVideoAuthor'
94 ]
95 ]
96 }
97 } as FindOptions
98 },
99 [ScopeNames.WITH_ACCOUNT]: { 55 [ScopeNames.WITH_ACCOUNT]: {
100 include: [ 56 include: [
101 { 57 {
@@ -103,22 +59,6 @@ export enum ScopeNames {
103 } 59 }
104 ] 60 ]
105 }, 61 },
106 [ScopeNames.WITH_ACCOUNT_FOR_API]: {
107 include: [
108 {
109 model: AccountModel.unscoped(),
110 include: [
111 {
112 attributes: {
113 exclude: unusedActorAttributesForAPI
114 },
115 model: ActorModel, // Default scope includes avatar and server
116 required: true
117 }
118 ]
119 }
120 ]
121 },
122 [ScopeNames.WITH_IN_REPLY_TO]: { 62 [ScopeNames.WITH_IN_REPLY_TO]: {
123 include: [ 63 include: [
124 { 64 {
@@ -319,93 +259,19 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
319 searchAccount?: string 259 searchAccount?: string
320 searchVideo?: string 260 searchVideo?: string
321 }) { 261 }) {
322 const { start, count, sort, isLocal, search, searchAccount, searchVideo, onLocalVideo } = parameters 262 const queryOptions: ListVideoCommentsOptions = {
323 263 ...pick(parameters, [ 'start', 'count', 'sort', 'isLocal', 'search', 'searchVideo', 'searchAccount', 'onLocalVideo' ]),
324 const where: WhereOptions = {
325 deletedAt: null
326 }
327 264
328 const whereAccount: WhereOptions = {} 265 selectType: 'api',
329 const whereActor: WhereOptions = {} 266 notDeleted: true
330 const whereVideo: WhereOptions = {}
331
332 if (isLocal === true) {
333 Object.assign(whereActor, {
334 serverId: null
335 })
336 } else if (isLocal === false) {
337 Object.assign(whereActor, {
338 serverId: {
339 [Op.ne]: null
340 }
341 })
342 }
343
344 if (search) {
345 Object.assign(where, {
346 [Op.or]: [
347 searchAttribute(search, 'text'),
348 searchAttribute(search, '$Account.Actor.preferredUsername$'),
349 searchAttribute(search, '$Account.name$'),
350 searchAttribute(search, '$Video.name$')
351 ]
352 })
353 }
354
355 if (searchAccount) {
356 Object.assign(whereActor, {
357 [Op.or]: [
358 searchAttribute(searchAccount, '$Account.Actor.preferredUsername$'),
359 searchAttribute(searchAccount, '$Account.name$')
360 ]
361 })
362 }
363
364 if (searchVideo) {
365 Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
366 }
367
368 if (exists(onLocalVideo)) {
369 Object.assign(whereVideo, { remote: !onLocalVideo })
370 }
371
372 const getQuery = (forCount: boolean) => {
373 return {
374 offset: start,
375 limit: count,
376 order: getCommentSort(sort),
377 where,
378 include: [
379 {
380 model: AccountModel.unscoped(),
381 required: true,
382 where: whereAccount,
383 include: [
384 {
385 attributes: {
386 exclude: unusedActorAttributesForAPI
387 },
388 model: forCount === true
389 ? ActorModel.unscoped() // Default scope includes avatar and server
390 : ActorModel,
391 required: true,
392 where: whereActor
393 }
394 ]
395 },
396 {
397 model: VideoModel.unscoped(),
398 required: true,
399 where: whereVideo
400 }
401 ]
402 }
403 } 267 }
404 268
405 return Promise.all([ 269 return Promise.all([
406 VideoCommentModel.count(getQuery(true)), 270 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
407 VideoCommentModel.findAll(getQuery(false)) 271 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
408 ]).then(([ total, data ]) => ({ total, data })) 272 ]).then(([ rows, count ]) => {
273 return { total: count, data: rows }
274 })
409 } 275 }
410 276
411 static async listThreadsForApi (parameters: { 277 static async listThreadsForApi (parameters: {
@@ -416,67 +282,40 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
416 sort: string 282 sort: string
417 user?: MUserAccountId 283 user?: MUserAccountId
418 }) { 284 }) {
419 const { videoId, isVideoOwned, start, count, sort, user } = parameters 285 const { videoId, user } = parameters
420 286
421 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) 287 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
422 288
423 const accountBlockedWhere = { 289 const commonOptions: ListVideoCommentsOptions = {
424 accountId: { 290 selectType: 'api',
425 [Op.notIn]: Sequelize.literal( 291 videoId,
426 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')' 292 blockerAccountIds
427 )
428 }
429 } 293 }
430 294
431 const queryList = { 295 const listOptions: ListVideoCommentsOptions = {
432 offset: start, 296 ...commonOptions,
433 limit: count, 297 ...pick(parameters, [ 'sort', 'start', 'count' ]),
434 order: getCommentSort(sort), 298
435 where: { 299 isThread: true,
436 [Op.and]: [ 300 includeReplyCounters: true
437 {
438 videoId
439 },
440 {
441 inReplyToCommentId: null
442 },
443 {
444 [Op.or]: [
445 accountBlockedWhere,
446 {
447 accountId: null
448 }
449 ]
450 }
451 ]
452 }
453 } 301 }
454 302
455 const findScopesList: (string | ScopeOptions)[] = [ 303 const countOptions: ListVideoCommentsOptions = {
456 ScopeNames.WITH_ACCOUNT_FOR_API, 304 ...commonOptions,
457 {
458 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
459 }
460 ]
461 305
462 const countScopesList: ScopeOptions[] = [ 306 isThread: true
463 { 307 }
464 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
465 }
466 ]
467 308
468 const notDeletedQueryCount = { 309 const notDeletedCountOptions: ListVideoCommentsOptions = {
469 where: { 310 ...commonOptions,
470 videoId, 311
471 deletedAt: null, 312 notDeleted: true
472 ...accountBlockedWhere
473 }
474 } 313 }
475 314
476 return Promise.all([ 315 return Promise.all([
477 VideoCommentModel.scope(findScopesList).findAll(queryList), 316 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminFormattable>(),
478 VideoCommentModel.scope(countScopesList).count(queryList), 317 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
479 VideoCommentModel.count(notDeletedQueryCount) 318 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
480 ]).then(([ rows, count, totalNotDeletedComments ]) => { 319 ]).then(([ rows, count, totalNotDeletedComments ]) => {
481 return { total: count, data: rows, totalNotDeletedComments } 320 return { total: count, data: rows, totalNotDeletedComments }
482 }) 321 })
@@ -484,54 +323,29 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
484 323
485 static async listThreadCommentsForApi (parameters: { 324 static async listThreadCommentsForApi (parameters: {
486 videoId: number 325 videoId: number
487 isVideoOwned: boolean
488 threadId: number 326 threadId: number
489 user?: MUserAccountId 327 user?: MUserAccountId
490 }) { 328 }) {
491 const { videoId, threadId, user, isVideoOwned } = parameters 329 const { user } = parameters
492 330
493 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned }) 331 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user })
494 332
495 const query = { 333 const queryOptions: ListVideoCommentsOptions = {
496 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order, 334 ...pick(parameters, [ 'videoId', 'threadId' ]),
497 where: {
498 videoId,
499 [Op.and]: [
500 {
501 [Op.or]: [
502 { id: threadId },
503 { originCommentId: threadId }
504 ]
505 },
506 {
507 [Op.or]: [
508 {
509 accountId: {
510 [Op.notIn]: Sequelize.literal(
511 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
512 )
513 }
514 },
515 {
516 accountId: null
517 }
518 ]
519 }
520 ]
521 }
522 }
523 335
524 const scopes: any[] = [ 336 selectType: 'api',
525 ScopeNames.WITH_ACCOUNT_FOR_API, 337 sort: 'createdAt',
526 { 338
527 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ] 339 blockerAccountIds,
528 } 340 includeReplyCounters: true
529 ] 341 }
530 342
531 return Promise.all([ 343 return Promise.all([
532 VideoCommentModel.count(query), 344 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminFormattable>(),
533 VideoCommentModel.scope(scopes).findAll(query) 345 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
534 ]).then(([ total, data ]) => ({ total, data })) 346 ]).then(([ rows, count ]) => {
347 return { total: count, data: rows }
348 })
535 } 349 }
536 350
537 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> { 351 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
@@ -559,31 +373,31 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
559 .findAll(query) 373 .findAll(query)
560 } 374 }
561 375
562 static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) { 376 static async listAndCountByVideoForAP (parameters: {
563 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ 377 video: MVideoImmutable
378 start: number
379 count: number
380 }) {
381 const { video } = parameters
382
383 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
384
385 const queryOptions: ListVideoCommentsOptions = {
386 ...pick(parameters, [ 'start', 'count' ]),
387
388 selectType: 'comment-only',
564 videoId: video.id, 389 videoId: video.id,
565 isVideoOwned: video.isOwned() 390 sort: 'createdAt',
566 })
567 391
568 const query = { 392 blockerAccountIds
569 order: [ [ 'createdAt', 'ASC' ] ] as Order,
570 offset: start,
571 limit: count,
572 where: {
573 videoId: video.id,
574 accountId: {
575 [Op.notIn]: Sequelize.literal(
576 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
577 )
578 }
579 },
580 transaction: t
581 } 393 }
582 394
583 return Promise.all([ 395 return Promise.all([
584 VideoCommentModel.count(query), 396 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
585 VideoCommentModel.findAll<MComment>(query) 397 new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
586 ]).then(([ total, data ]) => ({ total, data })) 398 ]).then(([ rows, count ]) => {
399 return { total: count, data: rows }
400 })
587 } 401 }
588 402
589 static async listForFeed (parameters: { 403 static async listForFeed (parameters: {
@@ -592,97 +406,36 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
592 videoId?: number 406 videoId?: number
593 accountId?: number 407 accountId?: number
594 videoChannelId?: number 408 videoChannelId?: number
595 }): Promise<MCommentOwnerVideoFeed[]> { 409 }) {
596 const serverActor = await getServerActor() 410 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
597 const { start, count, videoId, accountId, videoChannelId } = parameters
598 411
599 const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized( 412 const queryOptions: ListVideoCommentsOptions = {
600 '"VideoCommentModel"."accountId"', 413 ...pick(parameters, [ 'start', 'count', 'accountId', 'videoId', 'videoChannelId' ]),
601 [ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]
602 )
603 414
604 if (accountId) { 415 selectType: 'feed',
605 whereAnd.push({
606 accountId
607 })
608 }
609 416
610 const accountWhere = { 417 sort: '-createdAt',
611 [Op.and]: whereAnd 418 onPublicVideo: true,
612 } 419 notDeleted: true,
613
614 const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined
615 420
616 const query = { 421 blockerAccountIds
617 order: [ [ 'createdAt', 'DESC' ] ] as Order,
618 offset: start,
619 limit: count,
620 where: {
621 deletedAt: null,
622 accountId: accountWhere
623 },
624 include: [
625 {
626 attributes: [ 'name', 'uuid' ],
627 model: VideoModel.unscoped(),
628 required: true,
629 where: {
630 privacy: VideoPrivacy.PUBLIC
631 },
632 include: [
633 {
634 attributes: [ 'accountId' ],
635 model: VideoChannelModel.unscoped(),
636 required: true,
637 where: videoChannelWhere
638 }
639 ]
640 }
641 ]
642 } 422 }
643 423
644 if (videoId) query.where['videoId'] = videoId 424 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
645
646 return VideoCommentModel
647 .scope([ ScopeNames.WITH_ACCOUNT ])
648 .findAll(query)
649 } 425 }
650 426
651 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) { 427 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
652 const accountWhere = filter.onVideosOfAccount 428 const queryOptions: ListVideoCommentsOptions = {
653 ? { id: filter.onVideosOfAccount.id } 429 selectType: 'comment-only',
654 : {}
655 430
656 const query = { 431 accountId: ofAccount.id,
657 limit: 1000, 432 videoAccountOwnerId: filter.onVideosOfAccount?.id,
658 where: { 433
659 deletedAt: null, 434 notDeleted: true,
660 accountId: ofAccount.id 435 count: 5000
661 },
662 include: [
663 {
664 model: VideoModel,
665 required: true,
666 include: [
667 {
668 model: VideoChannelModel,
669 required: true,
670 include: [
671 {
672 model: AccountModel,
673 required: true,
674 where: accountWhere
675 }
676 ]
677 }
678 ]
679 }
680 ]
681 } 436 }
682 437
683 return VideoCommentModel 438 return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
684 .scope([ ScopeNames.WITH_ACCOUNT ])
685 .findAll(query)
686 } 439 }
687 440
688 static async getStats () { 441 static async getStats () {
@@ -750,9 +503,7 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
750 } 503 }
751 504
752 isOwned () { 505 isOwned () {
753 if (!this.Account) { 506 if (!this.Account) return false
754 return false
755 }
756 507
757 return this.Account.isOwned() 508 return this.Account.isOwned()
758 } 509 }
@@ -906,22 +657,15 @@ export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoComment
906 } 657 }
907 658
908 private static async buildBlockerAccountIds (options: { 659 private static async buildBlockerAccountIds (options: {
909 videoId: number 660 user: MUserAccountId
910 isVideoOwned: boolean 661 }): Promise<number[]> {
911 user?: MUserAccountId 662 const { user } = options
912 }) {
913 const { videoId, user, isVideoOwned } = options
914 663
915 const serverActor = await getServerActor() 664 const serverActor = await getServerActor()
916 const blockerAccountIds = [ serverActor.Account.id ] 665 const blockerAccountIds = [ serverActor.Account.id ]
917 666
918 if (user) blockerAccountIds.push(user.Account.id) 667 if (user) blockerAccountIds.push(user.Account.id)
919 668
920 if (isVideoOwned) {
921 const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
922 if (videoOwnerAccount) blockerAccountIds.push(videoOwnerAccount.id)
923 }
924
925 return blockerAccountIds 669 return blockerAccountIds
926 } 670 }
927} 671}