UNPKG

@adonis-agora/filter

Version:

Server-side query filtering/sorting/pagination for AdonisJS — Spatie-style input, a Lucid adapter, and field allow-listing. Part of the Agora ecosystem.

132 lines 5.9 kB
/** Column functions (need a numeric child column); `count` needs none. */ const COLUMN_FNS = ['sum', 'avg', 'min', 'max']; /** Double-quote one identifier segment for Postgres, escaping embedded quotes. */ function q(ident) { return `"${ident.replace(/"/g, '""')}"`; } /** * The scalar aggregate expression over an (already-quoted) child column. Empty * collection semantics: `sum` coalesces to 0; `avg`/`min`/`max` stay NULL (their * natural result over zero rows); `count` is naturally 0. */ function aggregateExpr(fn, quotedColumn) { switch (fn) { case 'count': return 'COUNT(*)'; case 'sum': return `COALESCE(SUM(${quotedColumn}),0)`; case 'avg': return `AVG(${quotedColumn})`; case 'min': return `MIN(${quotedColumn})`; case 'max': return `MAX(${quotedColumn})`; } } /** * ONE_TO_MANY correlation: a direct FK on the child row points back at the * root's PK. `column` omitted → `$count`. * * (SELECT <agg> FROM "child" WHERE "child"."fk" = "<alias>"."pk") */ function oneToManySource(fn, childTable, fk, pk, column) { const agg = aggregateExpr(fn, column ? `${q(childTable)}.${q(column)}` : ''); return ({ alias }) => `(SELECT ${agg} FROM ${q(childTable)} WHERE ${q(childTable)}.${q(fk)} = ${q(alias)}.${q(pk)})`; } /** * MANY_TO_MANY correlation, mediated by a pivot table. `$count` counts pivot * rows directly; the column functions JOIN pivot → child INSIDE the scalar * subquery to reach the child column. * * $count: (SELECT COUNT(*) FROM "pivot" WHERE "pivot"."ownerFk" = "<alias>"."ownerPk") * $agg: (SELECT <agg> FROM "child" * JOIN "pivot" ON "child"."childPk" = "pivot"."inverseFk" * WHERE "pivot"."ownerFk" = "<alias>"."ownerPk") */ function manyToManySource(fn, meta, column) { if (fn === 'count') { return ({ alias }) => `(SELECT COUNT(*) FROM ${q(meta.pivot)} WHERE ${q(meta.pivot)}.${q(meta.ownerFk)} = ${q(alias)}.${q(meta.ownerPk)})`; } const agg = aggregateExpr(fn, column ? `${q(meta.childTable)}.${q(column)}` : ''); return ({ alias }) => `(SELECT ${agg} FROM ${q(meta.childTable)} JOIN ${q(meta.pivot)} ON ${q(meta.childTable)}.${q(meta.childPk)} = ${q(meta.pivot)}.${q(meta.inverseFk)} WHERE ${q(meta.pivot)}.${q(meta.ownerFk)} = ${q(alias)}.${q(meta.ownerPk)})`; } /** * Discover to-many aggregate {@link ComputedSource}s from a Lucid model's * relation metadata, for the relations the spec whitelisted. For each declared * to-many relation (hasMany / manyToMany) this synthesises: * * - `<rel>.$count` — always; * - `<rel>.$sum|$avg|$min|$max.<col>` — one per column the relation's * {@link RelationSpec.aggregates} declares (Lucid does not reflect SQL column * types, so the dev asserts numeric-ness by listing the column there). * * To-one relations (belongsTo / hasOne) are excluded. The whole thing degrades * gracefully: a relation that is not on the model, is to-one, or cannot be * booted (e.g. no DB adapter yet) is simply skipped, and a model without the * introspection capability yields an empty map — so aggregates activate only * when the metadata is actually available. * * The result is merged into the spec's `computed` map, so aggregate fields ARE * computed fields with auto-generated correlated-subquery sources — they reuse * the exact same runner routing, allow-list bypass (their discovery IS their * allow-list, gated by the relation whitelist), injection-safe value binding, * and codegen surfacing. */ export function discoverAggregateSources(model, relations) { if (!model || typeof model.$getRelation !== 'function') return {}; const out = {}; for (const [relName, relSpec] of Object.entries(relations)) { let rel; try { rel = model.$getRelation(relName); if (!rel) continue; rel.boot(); } catch { continue; // Un-bootable relation (no adapter yet, etc.) → skip its aggregates. } const columns = relSpec.aggregates ?? []; const childTable = rel.relatedModel?.().table; if (rel.type === 'hasMany') { const fk = rel.foreignKeyColumnName; const pk = rel.localKeyColumnName; if (!childTable || !fk || !pk) continue; out[`${relName}.$count`] = oneToManySource('count', childTable, fk, pk, undefined); for (const col of columns) { for (const fn of COLUMN_FNS) { out[`${relName}.$${fn}.${col}`] = oneToManySource(fn, childTable, fk, pk, col); } } } else if (rel.type === 'manyToMany') { const pivot = rel.pivotTable; const ownerFk = rel.pivotForeignKey; const inverseFk = rel.pivotRelatedForeignKey; const ownerPk = rel.localKeyColumnName; const childPk = rel.relatedKeyColumnName; if (!pivot || !ownerFk || !ownerPk) continue; out[`${relName}.$count`] = manyToManySource('count', { childTable: childTable ?? '', childPk: childPk ?? '', pivot, ownerFk, inverseFk: inverseFk ?? '', ownerPk, }, undefined); if (childTable && inverseFk && childPk) { for (const col of columns) { for (const fn of COLUMN_FNS) { out[`${relName}.$${fn}.${col}`] = manyToManySource(fn, { childTable, childPk, pivot, ownerFk, inverseFk, ownerPk }, col); } } } } // to-one (belongsTo / hasOne) and any other kind → no aggregate keys. } return out; } //# sourceMappingURL=aggregate.js.map