diff options
Diffstat (limited to 'server/models/shared')
-rw-r--r-- | server/models/shared/index.ts | 4 | ||||
-rw-r--r-- | server/models/shared/model-cache.ts | 90 | ||||
-rw-r--r-- | server/models/shared/query.ts | 75 | ||||
-rw-r--r-- | server/models/shared/sequelize-helpers.ts | 39 | ||||
-rw-r--r-- | server/models/shared/sort.ts | 160 | ||||
-rw-r--r-- | server/models/shared/sql.ts | 68 | ||||
-rw-r--r-- | server/models/shared/update.ts | 14 |
7 files changed, 441 insertions, 9 deletions
diff --git a/server/models/shared/index.ts b/server/models/shared/index.ts index 04528929c..5a7621e4d 100644 --- a/server/models/shared/index.ts +++ b/server/models/shared/index.ts | |||
@@ -1,4 +1,8 @@ | |||
1 | export * from './abstract-run-query' | 1 | export * from './abstract-run-query' |
2 | export * from './model-builder' | 2 | export * from './model-builder' |
3 | export * from './model-cache' | ||
3 | export * from './query' | 4 | export * from './query' |
5 | export * from './sequelize-helpers' | ||
6 | export * from './sort' | ||
7 | export * from './sql' | ||
4 | export * from './update' | 8 | export * from './update' |
diff --git a/server/models/shared/model-cache.ts b/server/models/shared/model-cache.ts new file mode 100644 index 000000000..3651267e7 --- /dev/null +++ b/server/models/shared/model-cache.ts | |||
@@ -0,0 +1,90 @@ | |||
1 | import { Model } from 'sequelize-typescript' | ||
2 | import { logger } from '@server/helpers/logger' | ||
3 | |||
4 | type ModelCacheType = | ||
5 | 'local-account-name' | ||
6 | | 'local-actor-name' | ||
7 | | 'local-actor-url' | ||
8 | | 'load-video-immutable-id' | ||
9 | | 'load-video-immutable-url' | ||
10 | |||
11 | type DeleteKey = | ||
12 | 'video' | ||
13 | |||
14 | class ModelCache { | ||
15 | |||
16 | private static instance: ModelCache | ||
17 | |||
18 | private readonly localCache: { [id in ModelCacheType]: Map<string, any> } = { | ||
19 | 'local-account-name': new Map(), | ||
20 | 'local-actor-name': new Map(), | ||
21 | 'local-actor-url': new Map(), | ||
22 | 'load-video-immutable-id': new Map(), | ||
23 | 'load-video-immutable-url': new Map() | ||
24 | } | ||
25 | |||
26 | private readonly deleteIds: { | ||
27 | [deleteKey in DeleteKey]: Map<number, { cacheType: ModelCacheType, key: string }[]> | ||
28 | } = { | ||
29 | video: new Map() | ||
30 | } | ||
31 | |||
32 | private constructor () { | ||
33 | } | ||
34 | |||
35 | static get Instance () { | ||
36 | return this.instance || (this.instance = new this()) | ||
37 | } | ||
38 | |||
39 | doCache<T extends Model> (options: { | ||
40 | cacheType: ModelCacheType | ||
41 | key: string | ||
42 | fun: () => Promise<T> | ||
43 | whitelist?: () => boolean | ||
44 | deleteKey?: DeleteKey | ||
45 | }) { | ||
46 | const { cacheType, key, fun, whitelist, deleteKey } = options | ||
47 | |||
48 | if (whitelist && whitelist() !== true) return fun() | ||
49 | |||
50 | const cache = this.localCache[cacheType] | ||
51 | |||
52 | if (cache.has(key)) { | ||
53 | logger.debug('Model cache hit for %s -> %s.', cacheType, key) | ||
54 | return Promise.resolve<T>(cache.get(key)) | ||
55 | } | ||
56 | |||
57 | return fun().then(m => { | ||
58 | if (!m) return m | ||
59 | |||
60 | if (!whitelist || whitelist()) cache.set(key, m) | ||
61 | |||
62 | if (deleteKey) { | ||
63 | const map = this.deleteIds[deleteKey] | ||
64 | if (!map.has(m.id)) map.set(m.id, []) | ||
65 | |||
66 | const a = map.get(m.id) | ||
67 | a.push({ cacheType, key }) | ||
68 | } | ||
69 | |||
70 | return m | ||
71 | }) | ||
72 | } | ||
73 | |||
74 | invalidateCache (deleteKey: DeleteKey, modelId: number) { | ||
75 | const map = this.deleteIds[deleteKey] | ||
76 | |||
77 | if (!map.has(modelId)) return | ||
78 | |||
79 | for (const toDelete of map.get(modelId)) { | ||
80 | logger.debug('Removing %s -> %d of model cache %s -> %s.', deleteKey, modelId, toDelete.cacheType, toDelete.key) | ||
81 | this.localCache[toDelete.cacheType].delete(toDelete.key) | ||
82 | } | ||
83 | |||
84 | map.delete(modelId) | ||
85 | } | ||
86 | } | ||
87 | |||
88 | export { | ||
89 | ModelCache | ||
90 | } | ||
diff --git a/server/models/shared/query.ts b/server/models/shared/query.ts index 036cc13c6..934acc21f 100644 --- a/server/models/shared/query.ts +++ b/server/models/shared/query.ts | |||
@@ -1,17 +1,82 @@ | |||
1 | import { BindOrReplacements, QueryTypes } from 'sequelize' | 1 | import { BindOrReplacements, Op, QueryTypes, Sequelize } from 'sequelize' |
2 | import { sequelizeTypescript } from '@server/initializers/database' | 2 | import validator from 'validator' |
3 | import { forceNumber } from '@shared/core-utils' | ||
3 | 4 | ||
4 | function doesExist (query: string, bind?: BindOrReplacements) { | 5 | function doesExist (sequelize: Sequelize, query: string, bind?: BindOrReplacements) { |
5 | const options = { | 6 | const options = { |
6 | type: QueryTypes.SELECT as QueryTypes.SELECT, | 7 | type: QueryTypes.SELECT as QueryTypes.SELECT, |
7 | bind, | 8 | bind, |
8 | raw: true | 9 | raw: true |
9 | } | 10 | } |
10 | 11 | ||
11 | return sequelizeTypescript.query(query, options) | 12 | return sequelize.query(query, options) |
12 | .then(results => results.length === 1) | 13 | .then(results => results.length === 1) |
13 | } | 14 | } |
14 | 15 | ||
16 | function createSimilarityAttribute (col: string, value: string) { | ||
17 | return Sequelize.fn( | ||
18 | 'similarity', | ||
19 | |||
20 | searchTrigramNormalizeCol(col), | ||
21 | |||
22 | searchTrigramNormalizeValue(value) | ||
23 | ) | ||
24 | } | ||
25 | |||
26 | function buildWhereIdOrUUID (id: number | string) { | ||
27 | return validator.isInt('' + id) ? { id } : { uuid: id } | ||
28 | } | ||
29 | |||
30 | function parseAggregateResult (result: any) { | ||
31 | if (!result) return 0 | ||
32 | |||
33 | const total = forceNumber(result) | ||
34 | if (isNaN(total)) return 0 | ||
35 | |||
36 | return total | ||
37 | } | ||
38 | |||
39 | function parseRowCountResult (result: any) { | ||
40 | if (result.length !== 0) return result[0].total | ||
41 | |||
42 | return 0 | ||
43 | } | ||
44 | |||
45 | function createSafeIn (sequelize: Sequelize, toEscape: (string | number)[], additionalUnescaped: string[] = []) { | ||
46 | return toEscape.map(t => { | ||
47 | return t === null | ||
48 | ? null | ||
49 | : sequelize.escape('' + t) | ||
50 | }).concat(additionalUnescaped).join(', ') | ||
51 | } | ||
52 | |||
53 | function searchAttribute (sourceField?: string, targetField?: string) { | ||
54 | if (!sourceField) return {} | ||
55 | |||
56 | return { | ||
57 | [targetField]: { | ||
58 | // FIXME: ts error | ||
59 | [Op.iLike as any]: `%${sourceField}%` | ||
60 | } | ||
61 | } | ||
62 | } | ||
63 | |||
15 | export { | 64 | export { |
16 | doesExist | 65 | doesExist, |
66 | createSimilarityAttribute, | ||
67 | buildWhereIdOrUUID, | ||
68 | parseAggregateResult, | ||
69 | parseRowCountResult, | ||
70 | createSafeIn, | ||
71 | searchAttribute | ||
72 | } | ||
73 | |||
74 | // --------------------------------------------------------------------------- | ||
75 | |||
76 | function searchTrigramNormalizeValue (value: string) { | ||
77 | return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', value)) | ||
78 | } | ||
79 | |||
80 | function searchTrigramNormalizeCol (col: string) { | ||
81 | return Sequelize.fn('lower', Sequelize.fn('immutable_unaccent', Sequelize.col(col))) | ||
17 | } | 82 | } |
diff --git a/server/models/shared/sequelize-helpers.ts b/server/models/shared/sequelize-helpers.ts new file mode 100644 index 000000000..7af8471dc --- /dev/null +++ b/server/models/shared/sequelize-helpers.ts | |||
@@ -0,0 +1,39 @@ | |||
1 | import { Sequelize } from 'sequelize' | ||
2 | |||
3 | function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) { | ||
4 | if (!model.createdAt || !model.updatedAt) { | ||
5 | throw new Error('Miss createdAt & updatedAt attributes to model') | ||
6 | } | ||
7 | |||
8 | const now = Date.now() | ||
9 | const createdAtTime = model.createdAt.getTime() | ||
10 | const updatedAtTime = model.updatedAt.getTime() | ||
11 | |||
12 | return (now - createdAtTime) > refreshInterval && (now - updatedAtTime) > refreshInterval | ||
13 | } | ||
14 | |||
15 | function throwIfNotValid (value: any, validator: (value: any) => boolean, fieldName = 'value', nullable = false) { | ||
16 | if (nullable && (value === null || value === undefined)) return | ||
17 | |||
18 | if (validator(value) === false) { | ||
19 | throw new Error(`"${value}" is not a valid ${fieldName}.`) | ||
20 | } | ||
21 | } | ||
22 | |||
23 | function buildTrigramSearchIndex (indexName: string, attribute: string) { | ||
24 | return { | ||
25 | name: indexName, | ||
26 | // FIXME: gin_trgm_ops is not taken into account in Sequelize 6, so adding it ourselves in the literal function | ||
27 | fields: [ Sequelize.literal('lower(immutable_unaccent(' + attribute + ')) gin_trgm_ops') as any ], | ||
28 | using: 'gin', | ||
29 | operator: 'gin_trgm_ops' | ||
30 | } | ||
31 | } | ||
32 | |||
33 | // --------------------------------------------------------------------------- | ||
34 | |||
35 | export { | ||
36 | throwIfNotValid, | ||
37 | buildTrigramSearchIndex, | ||
38 | isOutdated | ||
39 | } | ||
diff --git a/server/models/shared/sort.ts b/server/models/shared/sort.ts new file mode 100644 index 000000000..77e84dcf4 --- /dev/null +++ b/server/models/shared/sort.ts | |||
@@ -0,0 +1,160 @@ | |||
1 | import { literal, OrderItem, Sequelize } from 'sequelize' | ||
2 | |||
3 | // Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ] | ||
4 | function getSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] { | ||
5 | const { direction, field } = buildSortDirectionAndField(value) | ||
6 | |||
7 | let finalField: string | ReturnType<typeof Sequelize.col> | ||
8 | |||
9 | if (field.toLowerCase() === 'match') { // Search | ||
10 | finalField = Sequelize.col('similarity') | ||
11 | } else { | ||
12 | finalField = field | ||
13 | } | ||
14 | |||
15 | return [ [ finalField, direction ], lastSort ] | ||
16 | } | ||
17 | |||
18 | function getAdminUsersSort (value: string): OrderItem[] { | ||
19 | const { direction, field } = buildSortDirectionAndField(value) | ||
20 | |||
21 | let finalField: string | ReturnType<typeof Sequelize.col> | ||
22 | |||
23 | if (field === 'videoQuotaUsed') { // Users list | ||
24 | finalField = Sequelize.col('videoQuotaUsed') | ||
25 | } else { | ||
26 | finalField = field | ||
27 | } | ||
28 | |||
29 | const nullPolicy = direction === 'ASC' | ||
30 | ? 'NULLS FIRST' | ||
31 | : 'NULLS LAST' | ||
32 | |||
33 | // FIXME: typings | ||
34 | return [ [ finalField as any, direction, nullPolicy ], [ 'id', 'ASC' ] ] | ||
35 | } | ||
36 | |||
37 | function getPlaylistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] { | ||
38 | const { direction, field } = buildSortDirectionAndField(value) | ||
39 | |||
40 | if (field.toLowerCase() === 'name') { | ||
41 | return [ [ 'displayName', direction ], lastSort ] | ||
42 | } | ||
43 | |||
44 | return getSort(value, lastSort) | ||
45 | } | ||
46 | |||
47 | function getCommentSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] { | ||
48 | const { direction, field } = buildSortDirectionAndField(value) | ||
49 | |||
50 | if (field === 'totalReplies') { | ||
51 | return [ | ||
52 | [ Sequelize.literal('"totalReplies"'), direction ], | ||
53 | lastSort | ||
54 | ] | ||
55 | } | ||
56 | |||
57 | return getSort(value, lastSort) | ||
58 | } | ||
59 | |||
60 | function getVideoSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] { | ||
61 | const { direction, field } = buildSortDirectionAndField(value) | ||
62 | |||
63 | if (field.toLowerCase() === 'trending') { // Sort by aggregation | ||
64 | return [ | ||
65 | [ Sequelize.fn('COALESCE', Sequelize.fn('SUM', Sequelize.col('VideoViews.views')), '0'), direction ], | ||
66 | |||
67 | [ Sequelize.col('VideoModel.views'), direction ], | ||
68 | |||
69 | lastSort | ||
70 | ] | ||
71 | } else if (field === 'publishedAt') { | ||
72 | return [ | ||
73 | [ 'ScheduleVideoUpdate', 'updateAt', direction + ' NULLS LAST' ], | ||
74 | |||
75 | [ Sequelize.col('VideoModel.publishedAt'), direction ], | ||
76 | |||
77 | lastSort | ||
78 | ] | ||
79 | } | ||
80 | |||
81 | let finalField: string | ReturnType<typeof Sequelize.col> | ||
82 | |||
83 | // Alias | ||
84 | if (field.toLowerCase() === 'match') { // Search | ||
85 | finalField = Sequelize.col('similarity') | ||
86 | } else { | ||
87 | finalField = field | ||
88 | } | ||
89 | |||
90 | const firstSort: OrderItem = typeof finalField === 'string' | ||
91 | ? finalField.split('.').concat([ direction ]) as OrderItem | ||
92 | : [ finalField, direction ] | ||
93 | |||
94 | return [ firstSort, lastSort ] | ||
95 | } | ||
96 | |||
97 | function getBlacklistSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] { | ||
98 | const { direction, field } = buildSortDirectionAndField(value) | ||
99 | |||
100 | const videoFields = new Set([ 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid' ]) | ||
101 | |||
102 | if (videoFields.has(field)) { | ||
103 | return [ | ||
104 | [ literal(`"Video.${field}" ${direction}`) ], | ||
105 | lastSort | ||
106 | ] as OrderItem[] | ||
107 | } | ||
108 | |||
109 | return getSort(value, lastSort) | ||
110 | } | ||
111 | |||
112 | function getInstanceFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]): OrderItem[] { | ||
113 | const { direction, field } = buildSortDirectionAndField(value) | ||
114 | |||
115 | if (field === 'redundancyAllowed') { | ||
116 | return [ | ||
117 | [ 'ActorFollowing.Server.redundancyAllowed', direction ], | ||
118 | lastSort | ||
119 | ] | ||
120 | } | ||
121 | |||
122 | return getSort(value, lastSort) | ||
123 | } | ||
124 | |||
125 | function getChannelSyncSort (value: string): OrderItem[] { | ||
126 | const { direction, field } = buildSortDirectionAndField(value) | ||
127 | if (field.toLowerCase() === 'videochannel') { | ||
128 | return [ | ||
129 | [ literal('"VideoChannel.name"'), direction ] | ||
130 | ] | ||
131 | } | ||
132 | return [ [ field, direction ] ] | ||
133 | } | ||
134 | |||
135 | function buildSortDirectionAndField (value: string) { | ||
136 | let field: string | ||
137 | let direction: 'ASC' | 'DESC' | ||
138 | |||
139 | if (value.substring(0, 1) === '-') { | ||
140 | direction = 'DESC' | ||
141 | field = value.substring(1) | ||
142 | } else { | ||
143 | direction = 'ASC' | ||
144 | field = value | ||
145 | } | ||
146 | |||
147 | return { direction, field } | ||
148 | } | ||
149 | |||
150 | export { | ||
151 | buildSortDirectionAndField, | ||
152 | getPlaylistSort, | ||
153 | getSort, | ||
154 | getCommentSort, | ||
155 | getAdminUsersSort, | ||
156 | getVideoSort, | ||
157 | getBlacklistSort, | ||
158 | getChannelSyncSort, | ||
159 | getInstanceFollowsSort | ||
160 | } | ||
diff --git a/server/models/shared/sql.ts b/server/models/shared/sql.ts new file mode 100644 index 000000000..5aaeb49f0 --- /dev/null +++ b/server/models/shared/sql.ts | |||
@@ -0,0 +1,68 @@ | |||
1 | import { literal, Model, ModelStatic } from 'sequelize' | ||
2 | import { forceNumber } from '@shared/core-utils' | ||
3 | import { AttributesOnly } from '@shared/typescript-utils' | ||
4 | |||
5 | function buildLocalAccountIdsIn () { | ||
6 | return literal( | ||
7 | '(SELECT "account"."id" FROM "account" INNER JOIN "actor" ON "actor"."id" = "account"."actorId" AND "actor"."serverId" IS NULL)' | ||
8 | ) | ||
9 | } | ||
10 | |||
11 | function buildLocalActorIdsIn () { | ||
12 | return literal( | ||
13 | '(SELECT "actor"."id" FROM "actor" WHERE "actor"."serverId" IS NULL)' | ||
14 | ) | ||
15 | } | ||
16 | |||
17 | function buildBlockedAccountSQL (blockerIds: number[]) { | ||
18 | const blockerIdsString = blockerIds.join(', ') | ||
19 | |||
20 | return 'SELECT "targetAccountId" AS "id" FROM "accountBlocklist" WHERE "accountId" IN (' + blockerIdsString + ')' + | ||
21 | ' UNION ' + | ||
22 | 'SELECT "account"."id" AS "id" FROM account INNER JOIN "actor" ON account."actorId" = actor.id ' + | ||
23 | 'INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ' + | ||
24 | 'WHERE "serverBlocklist"."accountId" IN (' + blockerIdsString + ')' | ||
25 | } | ||
26 | |||
27 | function buildServerIdsFollowedBy (actorId: any) { | ||
28 | const actorIdNumber = forceNumber(actorId) | ||
29 | |||
30 | return '(' + | ||
31 | 'SELECT "actor"."serverId" FROM "actorFollow" ' + | ||
32 | 'INNER JOIN "actor" ON actor.id = "actorFollow"."targetActorId" ' + | ||
33 | 'WHERE "actorFollow"."actorId" = ' + actorIdNumber + | ||
34 | ')' | ||
35 | } | ||
36 | |||
37 | function buildSQLAttributes<M extends Model> (options: { | ||
38 | model: ModelStatic<M> | ||
39 | tableName: string | ||
40 | |||
41 | excludeAttributes?: Exclude<keyof AttributesOnly<M>, symbol>[] | ||
42 | aliasPrefix?: string | ||
43 | }) { | ||
44 | const { model, tableName, aliasPrefix, excludeAttributes } = options | ||
45 | |||
46 | const attributes = Object.keys(model.getAttributes()) as Exclude<keyof AttributesOnly<M>, symbol>[] | ||
47 | |||
48 | return attributes | ||
49 | .filter(a => { | ||
50 | if (!excludeAttributes) return true | ||
51 | if (excludeAttributes.includes(a)) return false | ||
52 | |||
53 | return true | ||
54 | }) | ||
55 | .map(a => { | ||
56 | return `"${tableName}"."${a}" AS "${aliasPrefix || ''}${a}"` | ||
57 | }) | ||
58 | } | ||
59 | |||
60 | // --------------------------------------------------------------------------- | ||
61 | |||
62 | export { | ||
63 | buildSQLAttributes, | ||
64 | buildBlockedAccountSQL, | ||
65 | buildServerIdsFollowedBy, | ||
66 | buildLocalAccountIdsIn, | ||
67 | buildLocalActorIdsIn | ||
68 | } | ||
diff --git a/server/models/shared/update.ts b/server/models/shared/update.ts index d338211e3..d02c4535d 100644 --- a/server/models/shared/update.ts +++ b/server/models/shared/update.ts | |||
@@ -1,9 +1,15 @@ | |||
1 | import { QueryTypes, Transaction } from 'sequelize' | 1 | import { QueryTypes, Sequelize, Transaction } from 'sequelize' |
2 | import { sequelizeTypescript } from '@server/initializers/database' | ||
3 | 2 | ||
4 | // Sequelize always skip the update if we only update updatedAt field | 3 | // Sequelize always skip the update if we only update updatedAt field |
5 | function setAsUpdated (table: string, id: number, transaction?: Transaction) { | 4 | function setAsUpdated (options: { |
6 | return sequelizeTypescript.query( | 5 | sequelize: Sequelize |
6 | table: string | ||
7 | id: number | ||
8 | transaction?: Transaction | ||
9 | }) { | ||
10 | const { sequelize, table, id, transaction } = options | ||
11 | |||
12 | return sequelize.query( | ||
7 | `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`, | 13 | `UPDATE "${table}" SET "updatedAt" = :updatedAt WHERE id = :id`, |
8 | { | 14 | { |
9 | replacements: { table, id, updatedAt: new Date() }, | 15 | replacements: { table, id, updatedAt: new Date() }, |