From 0023c63be8445a8bf016b04b4fe5b571925d3e05 Mon Sep 17 00:00:00 2001 From: Maksim Eltyshev Date: Thu, 27 Nov 2025 19:09:10 +0100 Subject: [PATCH] fix: Optimize query methods --- server/api/hooks/query-methods/helpers.js | 8 + .../hooks/query-methods/models/Attachment.js | 23 +- .../query-methods/models/BackgroundImage.js | 23 +- server/api/hooks/query-methods/models/Card.js | 235 +++++++++--------- .../query-methods/models/CustomFieldValue.js | 16 +- server/api/hooks/query-methods/models/List.js | 8 +- .../query-methods/models/UploadedFile.js | 6 + server/api/hooks/query-methods/models/User.js | 31 +-- 8 files changed, 143 insertions(+), 207 deletions(-) diff --git a/server/api/hooks/query-methods/helpers.js b/server/api/hooks/query-methods/helpers.js index 78fc7cd6..851b82ce 100644 --- a/server/api/hooks/query-methods/helpers.js +++ b/server/api/hooks/query-methods/helpers.js @@ -31,6 +31,14 @@ const makeWhereQueryBuilder = (Model) => (criteria) => { return ['id = $1', [criteria]]; }; +const makeRowToModelTransformer = (Model) => { + // eslint-disable-next-line no-underscore-dangle + const transformations = _.invert(Model._transformer._transformations); + + return (row) => _.mapKeys(row, (_, key) => transformations[key]); +}; + module.exports = { makeWhereQueryBuilder, + makeRowToModelTransformer, }; diff --git a/server/api/hooks/query-methods/models/Attachment.js b/server/api/hooks/query-methods/models/Attachment.js index af668a8e..f1b72bcf 100644 --- a/server/api/hooks/query-methods/models/Attachment.js +++ b/server/api/hooks/query-methods/models/Attachment.js @@ -172,16 +172,7 @@ const delete_ = (criteria) => query += `END END, updated_at = $${queryValues.length} WHERE id IN (${inValues.join(', ')}) AND references_total IS NOT NULL RETURNING *`; const queryResult = await sails.sendNativeQuery(query, queryValues).usingConnection(db); - - uploadedFiles = queryResult.rows.map((row) => ({ - id: row.id, - type: row.type, - mimeType: row.mime_type, - size: row.size, - referencesTotal: row.references_total, - createdAt: row.created_at, - updatedAt: row.updated_at, - })); + uploadedFiles = queryResult.rows.map((row) => UploadedFile.qm.transformRowToModel(row)); } return { attachments, uploadedFiles }; @@ -200,17 +191,7 @@ const deleteOne = (criteria) => ) .usingConnection(db); - const [row] = queryResult.rows; - - uploadedFile = { - id: row.id, - type: row.type, - mimeType: row.mime_type, - size: row.size, - referencesTotal: row.references_total, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; + uploadedFile = UploadedFile.qm.transformRowToModel(queryResult.rows[0]); } return { attachment, uploadedFile }; diff --git a/server/api/hooks/query-methods/models/BackgroundImage.js b/server/api/hooks/query-methods/models/BackgroundImage.js index 8003f54d..4e79bdf9 100644 --- a/server/api/hooks/query-methods/models/BackgroundImage.js +++ b/server/api/hooks/query-methods/models/BackgroundImage.js @@ -105,16 +105,7 @@ const delete_ = (criteria) => query += `END END, updated_at = $${queryValues.length} WHERE id IN (${inValues.join(', ')}) AND references_total IS NOT NULL RETURNING *`; const queryResult = await sails.sendNativeQuery(query, queryValues).usingConnection(db); - - uploadedFiles = queryResult.rows.map((row) => ({ - id: row.id, - type: row.type, - mimeType: row.mime_type, - size: row.size, - referencesTotal: row.references_total, - createdAt: row.created_at, - updatedAt: row.updated_at, - })); + uploadedFiles = queryResult.rows.map((row) => UploadedFile.qm.transformRowToModel(row)); } return { backgroundImages, uploadedFiles }; @@ -131,17 +122,7 @@ const deleteOne = (criteria) => ) .usingConnection(db); - const [row] = queryResult.rows; - - uploadedFile = { - id: row.id, - type: row.type, - mimeType: row.mime_type, - size: row.size, - referencesTotal: row.references_total, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; + const uploadedFile = UploadedFile.qm.transformRowToModel(queryResult.rows[0]); return { backgroundImage, uploadedFile }; }); diff --git a/server/api/hooks/query-methods/models/Card.js b/server/api/hooks/query-methods/models/Card.js index 3dc66181..cefb45ef 100644 --- a/server/api/hooks/query-methods/models/Card.js +++ b/server/api/hooks/query-methods/models/Card.js @@ -4,102 +4,17 @@ */ const buildSearchParts = require('../../../../utils/build-query-parts'); +const { makeRowToModelTransformer } = require('../helpers'); const LIMIT = 50; +const transformRowToModel = makeRowToModelTransformer(Card); + const defaultFind = (criteria, { sort = 'id', limit } = {}) => Card.find(criteria).sort(sort).limit(limit); /* Query methods */ -const getIdsByEndlessListId = async (listId, { before, search, userIds, labelIds } = {}) => { - if (userIds && userIds.length === 0) { - return []; - } - - if (labelIds && labelIds.length === 0) { - return []; - } - - const queryValues = []; - let query = 'SELECT DISTINCT card.id FROM card'; - - if (userIds) { - query += ' LEFT JOIN card_membership ON card.id = card_membership.card_id'; - query += ' LEFT JOIN task_list ON card.id = task_list.card_id'; - query += ' LEFT JOIN task ON task_list.id = task.task_list_id'; - } - - if (labelIds) { - query += ' LEFT JOIN card_label ON card.id = card_label.card_id'; - } - - queryValues.push(listId); - query += ` WHERE card.list_id = $${queryValues.length}`; - - if (before) { - queryValues.push(before.listChangedAt); - query += ` AND (card.list_changed_at < $${queryValues.length} OR (card.list_changed_at = $${queryValues.length}`; - - queryValues.push(before.id); - query += ` AND card.id < $${queryValues.length}))`; - } - - if (search) { - if (search.startsWith('/')) { - queryValues.push(search.substring(1)); - query += ` AND (card.name ~* $${queryValues.length} OR card.description ~* $${queryValues.length})`; - } else { - const searchParts = buildSearchParts(search); - - if (searchParts.length > 0) { - const ilikeValues = searchParts.map((searchPart) => { - queryValues.push(searchPart); - return `'%' || $${queryValues.length} || '%'`; - }); - - query += ` AND ((card.name ILIKE ALL(ARRAY[${ilikeValues.join(', ')}])) OR (card.description ILIKE ALL(ARRAY[${ilikeValues.join(', ')}])))`; - } - } - } - - if (userIds) { - const inValues = userIds.map((userId) => { - queryValues.push(userId); - return `$${queryValues.length}`; - }); - - query += ` AND (card_membership.user_id IN (${inValues.join(', ')}) OR task.assignee_user_id IN (${inValues.join(', ')}))`; - } - - if (labelIds) { - const inValues = labelIds.map((labelId) => { - queryValues.push(labelId); - return `$${queryValues.length}`; - }); - - query += ` AND card_label.label_id IN (${inValues.join(', ')})`; - } - - query += ` LIMIT ${LIMIT}`; - - let queryResult; - try { - queryResult = await sails.sendNativeQuery(query, queryValues); - } catch (error) { - if ( - error.code === 'E_QUERY_FAILED' && - error.message.includes('Query failed: invalid regular expression') - ) { - return []; - } - - throw error; - } - - return sails.helpers.utils.mapRecords(queryResult.rows); -}; - const createOne = (values) => Card.create({ ...values }).fetch(); const getByIds = (ids) => defaultFind(ids); @@ -124,44 +39,120 @@ const getByListId = async (listId, { exceptIdOrIds, sort = ['position', 'id'] } }; const getByEndlessListId = async (listId, { before, search, userIds, labelIds }) => { - const criteria = {}; - - const options = { - sort: ['listChangedAt DESC', 'id DESC'], - }; - if (search || userIds || labelIds) { - criteria.id = await getIdsByEndlessListId(listId, { - before, - search, - userIds, - labelIds, - }); - } else { - criteria.and = [{ listId }]; - - if (before) { - criteria.and.push({ - or: [ - { - listChangedAt: { - '<': before.listChangedAt, - }, - }, - { - listChangedAt: before.listChangedAt, - id: { - '<': before.id, - }, - }, - ], - }); + if (userIds && userIds.length === 0) { + return []; } - options.limit = LIMIT; + if (labelIds && labelIds.length === 0) { + return []; + } + + const queryValues = []; + let query = 'SELECT DISTINCT card.* FROM card'; + + if (userIds) { + query += ' LEFT JOIN card_membership ON card.id = card_membership.card_id'; + query += ' LEFT JOIN task_list ON card.id = task_list.card_id'; + query += ' LEFT JOIN task ON task_list.id = task.task_list_id'; + } + + if (labelIds) { + query += ' LEFT JOIN card_label ON card.id = card_label.card_id'; + } + + queryValues.push(listId); + query += ` WHERE card.list_id = $${queryValues.length}`; + + if (before) { + queryValues.push(before.listChangedAt); + query += ` AND (card.list_changed_at < $${queryValues.length} OR (card.list_changed_at = $${queryValues.length}`; + + queryValues.push(before.id); + query += ` AND card.id < $${queryValues.length}))`; + } + + if (search) { + if (search.startsWith('/')) { + queryValues.push(search.substring(1)); + query += ` AND (card.name ~* $${queryValues.length} OR card.description ~* $${queryValues.length})`; + } else { + const searchParts = buildSearchParts(search); + + if (searchParts.length > 0) { + const ilikeValues = searchParts.map((searchPart) => { + queryValues.push(searchPart); + return `'%' || $${queryValues.length} || '%'`; + }); + + query += ` AND ((card.name ILIKE ALL(ARRAY[${ilikeValues.join(', ')}])) OR (card.description ILIKE ALL(ARRAY[${ilikeValues.join(', ')}])))`; + } + } + } + + if (userIds) { + const inValues = userIds.map((userId) => { + queryValues.push(userId); + return `$${queryValues.length}`; + }); + + query += ` AND (card_membership.user_id IN (${inValues.join(', ')}) OR task.assignee_user_id IN (${inValues.join(', ')}))`; + } + + if (labelIds) { + const inValues = labelIds.map((labelId) => { + queryValues.push(labelId); + return `$${queryValues.length}`; + }); + + query += ` AND card_label.label_id IN (${inValues.join(', ')})`; + } + + query += ` LIMIT ${LIMIT}`; + + let queryResult; + try { + queryResult = await sails.sendNativeQuery(query, queryValues); + } catch (error) { + if ( + error.code === 'E_QUERY_FAILED' && + error.message.includes('Query failed: invalid regular expression') + ) { + return []; + } + + throw error; + } + + return queryResult.rows.map((row) => transformRowToModel(row)); } - return defaultFind(criteria, options); + const criteria = { + and: [{ listId }], + }; + + if (before) { + criteria.and.push({ + or: [ + { + listChangedAt: { + '<': before.listChangedAt, + }, + }, + { + listChangedAt: before.listChangedAt, + id: { + '<': before.id, + }, + }, + ], + }); + } + + return defaultFind(criteria, { + sort: ['listChangedAt DESC', 'id DESC'], + limit: LIMIT, + }); }; const getByListIds = async (listIds, { sort = ['position', 'id'] } = {}) => @@ -242,8 +233,6 @@ const delete_ = (criteria) => Card.destroy(criteria).fetch(); const deleteOne = (criteria) => Card.destroyOne(criteria); module.exports = { - getIdsByEndlessListId, - createOne, getByIds, getByBoardId, diff --git a/server/api/hooks/query-methods/models/CustomFieldValue.js b/server/api/hooks/query-methods/models/CustomFieldValue.js index 5cea5248..c0f67a6c 100644 --- a/server/api/hooks/query-methods/models/CustomFieldValue.js +++ b/server/api/hooks/query-methods/models/CustomFieldValue.js @@ -3,6 +3,10 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ +const { makeRowToModelTransformer } = require('../helpers'); + +const transformRowToModel = makeRowToModelTransformer(CustomFieldValue); + const defaultFind = (criteria, { customFieldGroupIdOrIds }) => { if (customFieldGroupIdOrIds) { criteria.customFieldGroupId = customFieldGroupIdOrIds; // eslint-disable-line no-param-reassign @@ -32,17 +36,7 @@ const createOrUpdateOne = async (values) => { new Date().toISOString(), ]); - const [row] = queryResult.rows; - - return { - id: row.id, - cardId: row.card_id, - customFieldGroupId: row.custom_field_group_id, - customFieldId: row.custom_field_id, - content: row.content, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; + return transformRowToModel(queryResult.rows[0]); }; const getByIds = (ids) => defaultFind(ids); diff --git a/server/api/hooks/query-methods/models/List.js b/server/api/hooks/query-methods/models/List.js index 2de4725c..75aec55d 100644 --- a/server/api/hooks/query-methods/models/List.js +++ b/server/api/hooks/query-methods/models/List.js @@ -3,9 +3,10 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ -const { makeWhereQueryBuilder } = require('../helpers'); +const { makeRowToModelTransformer, makeWhereQueryBuilder } = require('../helpers'); const buildWhereQuery = makeWhereQueryBuilder(List); +const transformRowToModel = makeRowToModelTransformer(List); const defaultFind = (criteria, { sort = 'id' } = {}) => List.find(criteria).sort(sort); @@ -67,10 +68,7 @@ const updateOne = async (criteria, values) => { return { list: null }; } - const prev = { - boardId: queryResult.rows[0].board_id, - type: queryResult.rows[0].type, - }; + const prev = transformRowToModel(queryResult.rows[0]); const list = await List.updateOne(criteria) .set({ ...values }) diff --git a/server/api/hooks/query-methods/models/UploadedFile.js b/server/api/hooks/query-methods/models/UploadedFile.js index 2d973c2a..33364cce 100644 --- a/server/api/hooks/query-methods/models/UploadedFile.js +++ b/server/api/hooks/query-methods/models/UploadedFile.js @@ -3,12 +3,16 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ +const { makeRowToModelTransformer } = require('../helpers'); + const COLUMN_NAME_BY_TYPE = { [UploadedFile.Types.USER_AVATAR]: 'user_avatars', [UploadedFile.Types.BACKGROUND_IMAGE]: 'background_images', [UploadedFile.Types.ATTACHMENT]: 'attachments', }; +const transformRowToModel = makeRowToModelTransformer(UploadedFile); + /* Query methods */ const createOne = (values) => @@ -47,4 +51,6 @@ const deleteOne = (criteria) => module.exports = { createOne, deleteOne, + + transformRowToModel, }; diff --git a/server/api/hooks/query-methods/models/User.js b/server/api/hooks/query-methods/models/User.js index b6a1c7e4..80d3207f 100644 --- a/server/api/hooks/query-methods/models/User.js +++ b/server/api/hooks/query-methods/models/User.js @@ -3,7 +3,7 @@ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md */ -const { makeWhereQueryBuilder } = require('../helpers'); +const { makeRowToModelTransformer, makeWhereQueryBuilder } = require('../helpers'); const hasAvatarChanged = (avatar, prevAvatar) => { if (!avatar && !prevAvatar) { @@ -18,6 +18,7 @@ const hasAvatarChanged = (avatar, prevAvatar) => { }; const buildWhereQuery = makeWhereQueryBuilder(User); +const transformRowToModel = makeRowToModelTransformer(User); const defaultFind = (criteria) => User.find(criteria).sort('id'); @@ -117,9 +118,7 @@ const updateOne = async (criteria, values) => { return { user: null }; } - prev = { - avatar: queryResult.rows[0].avatar, - }; + prev = transformRowToModel(queryResult.rows[0]); } const user = await User.updateOne(criteria) @@ -136,17 +135,7 @@ const updateOne = async (criteria, values) => { ) .usingConnection(db); - const [row] = queryResult.rows; - - uploadedFile = { - id: row.id, - type: row.type, - mimeType: row.mime_type, - size: row.size, - referencesTotal: row.references_total, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; + uploadedFile = UploadedFile.qm.transformRowToModel(queryResult.rows[0]); } if (user.avatar) { @@ -184,17 +173,7 @@ const deleteOne = (criteria) => ) .usingConnection(db); - const [row] = queryResult.rows; - - uploadedFile = { - id: row.id, - type: row.type, - mimeType: row.mime_type, - size: row.size, - referencesTotal: row.references_total, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; + uploadedFile = UploadedFile.qm.transformRowToModel(queryResult.rows[0]); } return { user, uploadedFile };