|
| 1 | +import ObjectTools from './object-tools'; |
| 2 | +import Operators from './operators'; |
| 3 | +import { isVersionLessThan } from './orm'; |
| 4 | +import QueryUtils from './query'; |
| 5 | + |
| 6 | +/** |
| 7 | + * Extract all where conditions along the include tree, and bubbles them up to the top in-place. |
| 8 | + * This allows to work around a sequelize quirk that cause nested 'where' to fail when they |
| 9 | + * refer to relation fields from an intermediary include (ie '$book.id$'). |
| 10 | + * |
| 11 | + * This happens when forest admin filters on relations are used. |
| 12 | + * |
| 13 | + * @see https://sequelize.org/master/manual/eager-loading.html#complex-where-clauses-at-the-top-level |
| 14 | + * @see https://github.com/ForestAdmin/forest-express-sequelize/blob/7d7ad0/src/services/filters-parser.js#L104 |
| 15 | + */ |
| 16 | +function bubbleWheresInPlace(operators, options) { |
| 17 | + const parentIncludeList = options.include ?? []; |
| 18 | + |
| 19 | + parentIncludeList.forEach((include) => { |
| 20 | + bubbleWheresInPlace(operators, include); |
| 21 | + |
| 22 | + if (include.where) { |
| 23 | + const newWhere = ObjectTools.mapKeysDeep(include.where, (key) => ( |
| 24 | + key[0] === '$' && key[key.length - 1] === '$' |
| 25 | + ? `$${include.as}.${key.substring(1)}` |
| 26 | + : `$${include.as}.${key}$` |
| 27 | + )); |
| 28 | + |
| 29 | + options.where = QueryUtils.mergeWhere(operators, options.where, newWhere); |
| 30 | + delete include.where; |
| 31 | + } |
| 32 | + }); |
| 33 | +} |
| 34 | + |
| 35 | +/** |
| 36 | + * Includes can be expressed in different ways in sequelize, which is inconvenient to |
| 37 | + * remove duplicate associations. |
| 38 | + * This convert all valid ways to perform eager loading into [{model: X, as: 'x'}]. |
| 39 | + * |
| 40 | + * This is necessary as we have no control over which way customer use when writing SmartFields |
| 41 | + * search handlers. |
| 42 | + * |
| 43 | + * Among those: |
| 44 | + * - { include: [Book] } |
| 45 | + * - { include: [{ association: 'book' }] } |
| 46 | + * - { include: ['book'] } |
| 47 | + * - { include: [[{ as: 'book' }]] } |
| 48 | + * - { include: [[{ model: Book }]] } |
| 49 | + */ |
| 50 | +function normalizeInclude(model, include) { |
| 51 | + if (include.sequelize) { |
| 52 | + return { |
| 53 | + model: include, |
| 54 | + as: Object |
| 55 | + .keys(model.associations) |
| 56 | + .find((association) => model.associations[association].target.name === include.name), |
| 57 | + }; |
| 58 | + } |
| 59 | + |
| 60 | + if (typeof include === 'string' && model.associations[include]) { |
| 61 | + return { as: include, model: model.associations[include].target }; |
| 62 | + } |
| 63 | + |
| 64 | + if (typeof include === 'object') { |
| 65 | + if (typeof include.association === 'string' && model.associations[include.association]) { |
| 66 | + include.as = include.association; |
| 67 | + delete include.association; |
| 68 | + } |
| 69 | + |
| 70 | + if (typeof include.as === 'string' && !include.model && model.associations[include.as]) { |
| 71 | + const includeModel = model.associations[include.as].target; |
| 72 | + include.model = includeModel; |
| 73 | + } |
| 74 | + |
| 75 | + if (include.model && !include.as) { |
| 76 | + include.as = Object |
| 77 | + .keys(model.associations) |
| 78 | + .find((association) => model.associations[association].target.name === include.model.name); |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + // Recurse |
| 83 | + if (include.include) { |
| 84 | + include.include = include.include.map( |
| 85 | + (childInclude) => normalizeInclude(include.model, childInclude), |
| 86 | + ); |
| 87 | + } |
| 88 | + |
| 89 | + return include; |
| 90 | +} |
| 91 | + |
| 92 | +/** |
| 93 | + * Remove duplications in a queryOption.include array in-place. |
| 94 | + * Using multiple times the same association yields invalid SQL when using sequelize <= 4.x |
| 95 | + */ |
| 96 | +function removeDuplicateAssociations(model, includeList) { |
| 97 | + // Remove duplicates |
| 98 | + includeList.sort((include1, include2) => (include1.as < include2.as ? -1 : 1)); |
| 99 | + for (let i = 1; i < includeList.length; i += 1) { |
| 100 | + if (includeList[i - 1].as === includeList[i].as) { |
| 101 | + const newInclude = { ...includeList[i - 1], ...includeList[i] }; |
| 102 | + |
| 103 | + if (includeList[i - 1].attributes && includeList[i].attributes) { |
| 104 | + // Keep 'attributes' only when defined on both sides. |
| 105 | + newInclude.attributes = [...new Set([ |
| 106 | + ...includeList[i - 1].attributes, |
| 107 | + ...includeList[i].attributes, |
| 108 | + ])].sort(); |
| 109 | + } else { |
| 110 | + delete newInclude.attributes; |
| 111 | + } |
| 112 | + |
| 113 | + if (includeList[i - 1].include || includeList[i].include) { |
| 114 | + newInclude.include = [ |
| 115 | + ...(includeList[i - 1].include ?? []), |
| 116 | + ...(includeList[i].include ?? []), |
| 117 | + ]; |
| 118 | + } |
| 119 | + |
| 120 | + includeList[i - 1] = newInclude; |
| 121 | + includeList.splice(i, 1); |
| 122 | + i -= 1; |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + // Recurse |
| 127 | + includeList.forEach((include) => { |
| 128 | + if (include.include) { |
| 129 | + const association = model.associations[include.as]; |
| 130 | + removeDuplicateAssociations(association.target, include.include); |
| 131 | + } |
| 132 | + }); |
| 133 | +} |
| 134 | + |
| 135 | +exports.postProcess = (model, rawOptions) => { |
| 136 | + if (!rawOptions.include) return rawOptions; |
| 137 | + |
| 138 | + const options = rawOptions; |
| 139 | + const operators = Operators.getInstance({ Sequelize: model.sequelize.constructor }); |
| 140 | + |
| 141 | + if (isVersionLessThan(model.sequelize.constructor, '5.0.0')) { |
| 142 | + options.include = options.include.map((include) => normalizeInclude(model, include)); |
| 143 | + bubbleWheresInPlace(operators, options); |
| 144 | + removeDuplicateAssociations(model, options.include); |
| 145 | + } else { |
| 146 | + bubbleWheresInPlace(operators, options); |
| 147 | + } |
| 148 | + |
| 149 | + return options; |
| 150 | +}; |
0 commit comments