-
-
Notifications
You must be signed in to change notification settings - Fork 496
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(mariadb): rework pagination mechanism to fix extra results
MariaDB imposes restrictions on how subqueries work, breaking the default pagination mechanism, which returned all the rows instead of just the matching ones based on the subquery. This commit uses JSON array instead to get around this limitation.
- Loading branch information
Showing
6 changed files
with
650 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
import { | ||
type AnyEntity, | ||
type Dictionary, | ||
type EntityKey, | ||
type EntityMetadata, | ||
type PopulateOptions, | ||
raw, | ||
RawQueryFragment, | ||
} from '@mikro-orm/core'; | ||
import { QueryBuilder } from '@mikro-orm/knex'; | ||
|
||
/** | ||
* @inheritDoc | ||
*/ | ||
export class MariaDbQueryBuilder<T extends object = AnyEntity> extends QueryBuilder<T> { | ||
|
||
protected override wrapPaginateSubQuery(meta: EntityMetadata): void { | ||
const pks = this.prepareFields(meta.primaryKeys, 'sub-query') as string[]; | ||
const quotedPKs = pks.map(pk => this.platform.quoteIdentifier(pk)); | ||
const subQuery = this.clone(['_orderBy', '_fields']).select(pks).groupBy(pks).limit(this._limit!); | ||
// revert the on conditions added via populateWhere, we want to apply those only once | ||
// @ts-ignore | ||
Object.values(subQuery._joins).forEach(join => join.cond = join.cond_ ?? {}); | ||
|
||
if (this._offset) { | ||
subQuery.offset(this._offset); | ||
} | ||
|
||
const addToSelect = []; | ||
|
||
if (this._orderBy.length > 0) { | ||
const orderBy = []; | ||
|
||
for (const orderMap of this._orderBy) { | ||
for (const [field, direction] of Object.entries(orderMap)) { | ||
if (RawQueryFragment.isKnownFragment(field)) { | ||
const rawField = RawQueryFragment.getKnownFragment(field, false)!; | ||
this.rawFragments.add(field); | ||
orderBy.push({ [rawField.clone() as any]: direction }); | ||
continue; | ||
} | ||
|
||
const [a, f] = this.helper.splitField(field as EntityKey<T>); | ||
const prop = this.helper.getProperty(f, a); | ||
const type = this.platform.castColumn(prop); | ||
const fieldName = this.helper.mapper(field, this.type, undefined, null); | ||
|
||
if (!prop?.persist && !prop?.formula && !pks.includes(fieldName)) { | ||
addToSelect.push(fieldName); | ||
} | ||
|
||
const key = raw(`min(${this.knex.ref(fieldName)}${type})`); | ||
orderBy.push({ [key]: direction }); | ||
} | ||
} | ||
|
||
subQuery.orderBy(orderBy); | ||
} | ||
|
||
// @ts-ignore | ||
subQuery.finalized = true; | ||
const knexQuery = subQuery.as(this.mainAlias.aliasName).clearSelect().select(pks); | ||
|
||
if (addToSelect.length > 0) { | ||
addToSelect.forEach(prop => { | ||
const field = this._fields!.find(field => { | ||
if (typeof field === 'object' && field && '__as' in field) { | ||
return field.__as === prop; | ||
} | ||
|
||
if (field instanceof RawQueryFragment) { | ||
// not perfect, but should work most of the time, ideally we should check only the alias (`... as alias`) | ||
return field.sql.includes(prop); | ||
} | ||
|
||
return false; | ||
}); | ||
|
||
if (field instanceof RawQueryFragment) { | ||
knexQuery.select(this.platform.formatQuery(field.sql, field.params)); | ||
} else if (field) { | ||
knexQuery.select(field as string); | ||
} | ||
}); | ||
} | ||
|
||
// multiple sub-queries are needed to get around mysql limitations with order by + limit + where in + group by (o.O) | ||
// https://stackoverflow.com/questions/17892762/mysql-this-version-of-mysql-doesnt-yet-support-limit-in-all-any-some-subqu | ||
const subSubQuery = this.getKnex().select(this.knex.raw(`json_arrayagg(${quotedPKs.join(', ')})`)).from(knexQuery); | ||
(subSubQuery as Dictionary).__raw = true; // tag it as there is now way to check via `instanceof` | ||
this._limit = undefined; | ||
this._offset = undefined; | ||
|
||
// remove joins that are not used for population or ordering to improve performance | ||
const populate = new Set<string>(); | ||
const orderByAliases = this._orderBy | ||
.flatMap(hint => Object.keys(hint)) | ||
.map(k => k.split('.')[0]); | ||
|
||
function addPath(hints: PopulateOptions<any>[], prefix = '') { | ||
for (const hint of hints) { | ||
const field = hint.field.split(':')[0]; | ||
populate.add((prefix ? prefix + '.' : '') + field); | ||
|
||
if (hint.children) { | ||
addPath(hint.children, (prefix ? prefix + '.' : '') + field); | ||
} | ||
} | ||
} | ||
|
||
addPath(this._populate); | ||
|
||
for (const [key, join] of Object.entries(this._joins)) { | ||
const path = join.path?.replace(/\[populate]|\[pivot]|:ref/g, '').replace(new RegExp(`^${meta.className}.`), ''); | ||
|
||
if (!populate.has(path ?? '') && !orderByAliases.includes(join.alias)) { | ||
delete this._joins[key]; | ||
} | ||
} | ||
|
||
const subquerySql = subSubQuery.toString(); | ||
const key = meta.getPrimaryProps()[0].runtimeType === 'string' ? `concat('"', ${quotedPKs.join(', ')}, '"')` : quotedPKs.join(', '); | ||
const sql = `json_contains((${subquerySql}), ${key})`; | ||
this._cond = {}; | ||
this.select(this._fields!).where(sql); | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.