Après environ une semaine d'enfer, j'ai trouvé une solution de contournement acceptable pour mon cas. Je pense que cela serait utile car j'ai trouvé beaucoup de sujets/problèmes sans réponse sur github.
TL;DR; la solution réelle est à la fin du message, juste le dernier morceau de code.
L'idée principale est que Sequelize construit une requête SQL correcte, mais lorsque nous avons des jointures à gauche, nous produisons un produit carthésien, il y aura donc beaucoup de lignes comme résultat de la requête.
Exemple :tableaux A et B. Relation plusieurs à plusieurs. Si nous voulons que tous les A soient joints à B, nous recevrons les lignes A * B, il y aura donc beaucoup de lignes pour chaque enregistrement de A avec des valeurs différentes de 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
Dans la syntaxe sequelize :
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’ }],
})
Tout fonctionne bien.
Alors, imaginez que je veux recevoir 10 enregistrements de A avec des enregistrements mappés sur eux de B.Lorsque nous mettons LIMIT 10 sur cette requête, Sequelize construit une requête correcte mais LIMIT est appliqué à l'ensemble de la requête et, par conséquent, nous ne recevons que 10 lignes, où tous d'entre eux pourraient être pour un seul enregistrement de A. Exemple :
A.findAll({
distinct: true,
include: [{ association: ‘ab’ }],
limit: 10,
})
Qui sera converti en :
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
Une fois la sortie reçue, Seruqlize en tant qu'ORM effectuera le mappage des données et le résultat de la requête dans le code sera :
[
{
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 },
],
}
]
Évidemment PAS ce que nous voulions. Je voulais recevoir 10 enregistrements pour A, mais je n'en ai reçu que 2, alors que je sais qu'il y en a plus dans la base de données.
Nous avons donc une requête SQL correcte mais nous avons toujours reçu un résultat incorrect.
Bon j'avais quelques idées mais la plus simple et la plus logique c'est :1. Effectuez la première requête avec des jointures et regroupez les résultats par table source (table sur laquelle nous effectuons une requête et à laquelle nous effectuons des jointures) propriété 'id'. Ça a l'air facile.....
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.
- Nous recevrons maintenant une sortie correcte avec uniquement les identifiants de ressources nécessaires. Ensuite, nous devons composer la requête seconf SANS limite ni décalage, mais spécifier une clause 'where' supplémentaire :
{
...,
where: {
...,
id: Sequelize.Op.in: [array of ids],
}
}
- Avec une requête sur, nous pouvons produire une requête correcte avec LEFT JOINS.
Solution La méthode reçoit le modèle et la requête d'origine en tant qu'arguments et renvoie la requête correcte + en plus le nombre total d'enregistrements dans la base de données pour la pagination. Il analyse également correctement l'ordre des requêtes pour permettre de trier par champs à partir de tables jointes :
/**
* 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));
}