След около седмица ад намерих приемливо решение за моя случай. Вярвам, че би било полезно, тъй като намерих много теми/проблеми без отговор в github.
TL;DR; действителното решение е в края на публикацията, само последната част от кода.
Основната идея е, че Sequelize изгражда правилна SQL заявка, но когато имаме леви съединения, произвеждаме декартово произведение, така че ще има много редове като резултат от заявката.
Пример:A и B таблици. Отношение много към много. Ако искаме да обединим всички A с B, ще получим редове A * B, така че ще има много редове за всеки запис от A с различни стойности от B.
CREATE TABLE IF NOT EXISTS a (
id INTEGER PRIMARY KEY NOT NULL,
title VARCHAR
)
CREATE TABLE IF NOT EXISTS b (
id INTEGER PRIMARY KEY NOT NULL,
age INTEGER
)
CREATE TABLE IF NOT EXISTS ab (
id INTEGER PRIMARY KEY NOT NULL,
aid INTEGER,
bid INTEGER
)
SELECT *
FROM a
LEFT JOIN (ab JOIN b ON b.id = ab.bid) ON a.id = ab.aid
В синтаксиса на последователността:
class A extends Model {}
A.init({
id: {
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
},
title: {
type: Sequelize.STRING,
},
});
class B extends Model {}
B.init({
id: {
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
},
age: {
type: Sequelize.INTEGER,
},
});
A.belongsToMany(B, { foreignKey: ‘aid’, otherKey: ‘bid’, as: ‘ab’ });
B.belongsToMany(A, { foreignKey: ‘bid’, otherKey: ‘aid’, as: ‘ab’ });
A.findAll({
distinct: true,
include: [{ association: ‘ab’ }],
})
Всичко работи добре.
И така, представете си, че искам да получа 10 записа от A със съпоставени към тях записи от B. Когато поставим LIMIT 10 на тази заявка, Sequelize изгражда правилната заявка, но LIMIT се прилага към цялата заявка и като резултат получаваме само 10 реда, където всички от тях може да бъде само за един запис от A. Пример:
A.findAll({
distinct: true,
include: [{ association: ‘ab’ }],
limit: 10,
})
Което ще бъде преобразувано в:
SELECT *
FROM a
LEFT JOIN (ab JOIN b ON b.id = ab.bid) ON a.id = ab.aid
LIMIT 10
id | title | id | aid | bid | id | age
--- | -------- | ----- | ----- | ----- | ----- | -----
1 | first | 1 | 1 | 1 | 1 | 1
1 | first | 2 | 1 | 2 | 2 | 2
1 | first | 3 | 1 | 3 | 3 | 3
1 | first | 4 | 1 | 4 | 4 | 4
1 | first | 5 | 1 | 5 | 5 | 5
2 | second | 6 | 2 | 5 | 5 | 5
2 | second | 7 | 2 | 4 | 4 | 4
2 | second | 8 | 2 | 3 | 3 | 3
2 | second | 9 | 2 | 2 | 2 | 2
2 | second | 10 | 2 | 1 | 1 | 1
След като изходът бъде получен, Seruqlize като ORM ще направи картографиране на данни и резултатът от заявката в кода ще бъде:
[
{
id: 1,
title: 'first',
ab: [
{ id: 1, age:1 },
{ id: 2, age:2 },
{ id: 3, age:3 },
{ id: 4, age:4 },
{ id: 5, age:5 },
],
},
{
id: 2,
title: 'second',
ab: [
{ id: 5, age:5 },
{ id: 4, age:4 },
{ id: 3, age:3 },
{ id: 2, age:2 },
{ id: 1, age:1 },
],
}
]
Очевидно НЕ това, което искахме. Исках да получа 10 записа за A, но получих само 2, докато знам, че има още в базата данни.
Така че имаме правилна SQL заявка, но все пак получихме неправилен резултат.
Добре, имах няколко идеи, но най-лесната и най-логичната е:1. Направете първа заявка с присъединявания и групирайте резултатите по таблица източник (таблица, към която правим заявка и към която правим присъединявания) свойство 'id'. Изглежда лесно.....
To make so we need to provide 'group' property to Sequelize query options. Here we have some problems. First - Sequelize makes aliases for each table while generating SQL query. Second - Sequelize puts all columns from JOINED table into SELECT statement of its query and passing __'attributes' = []__ won't help. In both cases we'll receive SQL error.
To solve first we need to convert Model.tableName to singluar form of this word (this logic is based on Sequelize). Just use [pluralize.singular()](https://www.npmjs.com/package/pluralize#usage). Then compose correct property to GROUP BY:
```ts
const tableAlias = pluralize.singular('Industries') // Industry
{
...,
group: [`${tableAlias}.id`]
}
```
To solve second (it was the hardest and the most ... undocumented). We need to use undocumented property 'includeIgnoreAttributes' = false. This will remove all columns from SELECT statement unless we specify some manually. We should manually specify attributes = ['id'] on root query.
- Сега ще получим правилен изход само с идентификатори на необходимите ресурси. След това трябва да съставим втора заявка БЕЗ ограничение и отместване, но да посочим допълнителна клауза „къде“:
{
...,
where: {
...,
id: Sequelize.Op.in: [array of ids],
}
}
- Със заявка относно можем да създадем правилна заявка с ЛЕВИ СЪЕДИНЕНИЯ.
Решение Методът получава модел и оригинална заявка като аргументи и връща правилна заявка + допълнително общо количество записи в DB за страниране. Той също така правилно анализира реда на заявките, за да предостави възможност за подреждане по полета от обединени таблици:
/**
* Workaround for Sequelize illogical behavior when querying with LEFT JOINS and having LIMIT / OFFSET
*
* Here we group by 'id' prop of main (source) model, abd using undocumented 'includeIgnoreAttributes'
* Sequelize prop (it is used in its static count() method) in order to get correct SQL request
* Witout usage of 'includeIgnoreAttributes' there are a lot of extra invalid columns in SELECT statement
*
* Incorrect example without 'includeIgnoreAttributes'. Here we will get correct SQL query
* BUT useless according to business logic:
*
* SELECT "Media"."id", "Solutions->MediaSolutions"."mediaId", "Industries->MediaIndustries"."mediaId",...,
* FROM "Medias" AS "Media"
* LEFT JOIN ...
* WHERE ...
* GROUP BY "Media"."id"
* ORDER BY ...
* LIMIT ...
* OFFSET ...
*
* Correct example with 'includeIgnoreAttributes':
*
* SELECT "Media"."id"
* FROM "Medias" AS "Media"
* LEFT JOIN ...
* WHERE ...
* GROUP BY "Media"."id"
* ORDER BY ...
* LIMIT ...
* OFFSET ...
*
* @param model - Source model (necessary for getting its tableName for GROUP BY option)
* @param query - Parsed and ready to use query object
*/
private async fixSequeliseQueryWithLeftJoins<C extends Model>(
model: ModelCtor<C>, query: FindAndCountOptions,
): IMsgPromise<{ query: FindAndCountOptions; total?: number }> {
const fixedQuery: FindAndCountOptions = { ...query };
// If there is only Tenant data joined -> return original query
if (query.include && query.include.length === 1 && (query.include[0] as IncludeOptions).model === Tenant) {
return msg.ok({ query: fixedQuery });
}
// Here we need to put it to singular form,
// because Sequelize gets singular form for models AS aliases in SQL query
const modelAlias = singular(model.tableName);
const firstQuery = {
...fixedQuery,
group: [`${modelAlias}.id`],
attributes: ['id'],
raw: true,
includeIgnoreAttributes: false,
logging: true,
};
// Ordering by joined table column - when ordering by joined data need to add it into the group
if (Array.isArray(firstQuery.order)) {
firstQuery.order.forEach((item) => {
if ((item as GenericObject).length === 2) {
firstQuery.group.push(`${modelAlias}.${(item as GenericObject)[0]}`);
} else if ((item as GenericObject).length === 3) {
firstQuery.group.push(`${(item as GenericObject)[0]}.${(item as GenericObject)[1]}`);
}
});
}
return model.findAndCountAll<C>(firstQuery)
.then((ids) => {
if (ids && ids.rows && ids.rows.length) {
fixedQuery.where = {
...fixedQuery.where,
id: {
[Op.in]: ids.rows.map((item: GenericObject) => item.id),
},
};
delete fixedQuery.limit;
delete fixedQuery.offset;
}
/* eslint-disable-next-line */
const total = (ids.count as any).length || ids.count;
return msg.ok({ query: fixedQuery, total });
})
.catch((err) => this.createCustomError(err));
}