From 39726a165b07bfb22f1124fcd0d0631d89966192 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Wed, 8 Jan 2025 18:07:54 +0100 Subject: [PATCH 1/5] feat: add lazy join decorator --- packages/_example/src/forest/agent.ts | 2 +- .../src/decorators/decorators-stack.ts | 2 + .../src/decorators/lazy-join/collection.ts | 93 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 packages/datasource-customizer/src/decorators/lazy-join/collection.ts diff --git a/packages/_example/src/forest/agent.ts b/packages/_example/src/forest/agent.ts index facd1f911b..18ff14c5d8 100644 --- a/packages/_example/src/forest/agent.ts +++ b/packages/_example/src/forest/agent.ts @@ -28,7 +28,7 @@ export default function makeAgent() { envSecret: process.env.FOREST_ENV_SECRET, forestServerUrl: process.env.FOREST_SERVER_URL, isProduction: false, - loggerLevel: 'Info', + loggerLevel: 'Debug', typingsPath: 'src/forest/typings.ts', }; diff --git a/packages/datasource-customizer/src/decorators/decorators-stack.ts b/packages/datasource-customizer/src/decorators/decorators-stack.ts index ecb0c217e1..58fdf8c16c 100644 --- a/packages/datasource-customizer/src/decorators/decorators-stack.ts +++ b/packages/datasource-customizer/src/decorators/decorators-stack.ts @@ -7,6 +7,7 @@ import ComputedCollectionDecorator from './computed/collection'; import DecoratorsStackBase, { Options } from './decorators-stack-base'; import EmptyCollectionDecorator from './empty/collection'; import HookCollectionDecorator from './hook/collection'; +import LazyJoinDecorator from './lazy-join/collection'; import OperatorsEmulateCollectionDecorator from './operators-emulate/collection'; import OperatorsEquivalenceCollectionDecorator from './operators-equivalence/collection'; import OverrideCollectionDecorator from './override/collection'; @@ -39,6 +40,7 @@ export default class DecoratorsStack extends DecoratorsStackBase { last = this.earlyOpEmulate = new DataSourceDecorator(last, OperatorsEmulateCollectionDecorator); last = new DataSourceDecorator(last, OperatorsEquivalenceCollectionDecorator); last = this.relation = new DataSourceDecorator(last, RelationCollectionDecorator); + last = new DataSourceDecorator(last, LazyJoinDecorator); last = this.lateComputed = new DataSourceDecorator(last, ComputedCollectionDecorator); last = this.lateOpEmulate = new DataSourceDecorator(last, OperatorsEmulateCollectionDecorator); last = new DataSourceDecorator(last, OperatorsEquivalenceCollectionDecorator); diff --git a/packages/datasource-customizer/src/decorators/lazy-join/collection.ts b/packages/datasource-customizer/src/decorators/lazy-join/collection.ts new file mode 100644 index 0000000000..7de8121447 --- /dev/null +++ b/packages/datasource-customizer/src/decorators/lazy-join/collection.ts @@ -0,0 +1,93 @@ +import { + Caller, + CollectionDecorator, + FieldSchema, + ManyToOneSchema, + PaginatedFilter, + Projection, + RecordData, +} from '@forestadmin/datasource-toolkit'; + +export default class LazyJoinDecorator extends CollectionDecorator { + override async list( + caller: Caller, + filter: PaginatedFilter, + projection: Projection, + ): Promise { + const refinedProjection = this.refineProjection(projection); + const refinedFilter = await this.refineFilter(caller, filter); + + const records = await this.childCollection.list(caller, refinedFilter, refinedProjection); + + return this.refineRecords(records, projection); + } + + private isLazyRelationProjection(relation: FieldSchema, relationProjection: Projection) { + return ( + relation.type === 'ManyToOne' && + relationProjection.length === 1 && + relationProjection[0] === relation.foreignKeyTarget + ); + } + + private refineProjection(projection: Projection): Projection { + const newProjection = new Projection(...projection); + + Object.entries(newProjection.relations).forEach(([relationName, relationProjection]) => { + const relation = this.schema.fields[relationName] as ManyToOneSchema; + + if (this.isLazyRelationProjection(relation, relationProjection)) { + const index = newProjection.findIndex(p => p.startsWith(relationName)); + + newProjection[index] = relation.foreignKey; + } + }); + + return newProjection; + } + + override async refineFilter(caller: Caller, filter: PaginatedFilter): Promise { + if (filter.conditionTree) { + const relationToRefine: Record = Object.entries( + filter.conditionTree.projection.relations, + ).reduce((relations, [relationName, relationProjection]) => { + const relation = this.schema.fields[relationName] as ManyToOneSchema; + + if (this.isLazyRelationProjection(relation, relationProjection)) { + relations[relationName] = relation; + } + + return relations; + }, {}); + + filter.conditionTree.replaceLeafs(leaf => { + const relationName = Object.keys(leaf.projection.relations)[0]; + + if (relationName && relationToRefine[relationName]) { + leaf.field = relationToRefine[relationName].foreignKey; + } + + return leaf; + }); + } + + return filter; + } + + private refineRecords(records: RecordData[], projection: Projection): RecordData[] { + Object.entries(projection.relations).forEach(([relationName, relationProjection]) => { + const relation = this.schema.fields[relationName] as ManyToOneSchema; + + if (this.isLazyRelationProjection(relation, relationProjection)) { + const { foreignKeyTarget, foreignKey } = relation; + + records.forEach(record => { + record[relationName] = { [foreignKeyTarget]: record[foreignKey] }; + delete record[foreignKey]; + }); + } + }); + + return records; + } +} From f104b4c478be412976b014e9e15869d33954f9f0 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Fri, 10 Jan 2025 16:01:36 +0100 Subject: [PATCH 2/5] chore: put log info --- packages/_example/src/forest/agent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/_example/src/forest/agent.ts b/packages/_example/src/forest/agent.ts index 18ff14c5d8..facd1f911b 100644 --- a/packages/_example/src/forest/agent.ts +++ b/packages/_example/src/forest/agent.ts @@ -28,7 +28,7 @@ export default function makeAgent() { envSecret: process.env.FOREST_ENV_SECRET, forestServerUrl: process.env.FOREST_SERVER_URL, isProduction: false, - loggerLevel: 'Debug', + loggerLevel: 'Info', typingsPath: 'src/forest/typings.ts', }; From 7d3d391c8afd08c6fe954951a3c324e6594211c9 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Mon, 13 Jan 2025 17:19:18 +0100 Subject: [PATCH 3/5] test: add test --- .../src/decorators/lazy-join/collection.ts | 5 +- .../decorators/lazy-join/collection.test.ts | 206 ++++++++++++++++++ 2 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts diff --git a/packages/datasource-customizer/src/decorators/lazy-join/collection.ts b/packages/datasource-customizer/src/decorators/lazy-join/collection.ts index 7de8121447..25088c0b79 100644 --- a/packages/datasource-customizer/src/decorators/lazy-join/collection.ts +++ b/packages/datasource-customizer/src/decorators/lazy-join/collection.ts @@ -82,7 +82,10 @@ export default class LazyJoinDecorator extends CollectionDecorator { const { foreignKeyTarget, foreignKey } = relation; records.forEach(record => { - record[relationName] = { [foreignKeyTarget]: record[foreignKey] }; + if (record[foreignKey]) { + record[relationName] = { [foreignKeyTarget]: record[foreignKey] }; + } + delete record[foreignKey]; }); } diff --git a/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts b/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts new file mode 100644 index 0000000000..2c94fd23c2 --- /dev/null +++ b/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts @@ -0,0 +1,206 @@ +import { + Caller, + Collection, + DataSource, + DataSourceDecorator, + PaginatedFilter, + Projection, +} from '@forestadmin/datasource-toolkit'; +import * as factories from '@forestadmin/datasource-toolkit/dist/test/__factories__'; + +import LazyJoinDecorator from '../../../src/decorators/lazy-join/collection'; + +describe('LazyJoinDecorator', () => { + let dataSource: DataSource; + let decoratedDataSource: DataSourceDecorator; + + let transactions: Collection; + let decoratedTransactions: LazyJoinDecorator; + + beforeEach(() => { + const card = factories.collection.build({ + name: 'cards', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + type: factories.columnSchema.build(), + }, + }), + }); + + const user = factories.collection.build({ + name: 'uses', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.build(), + }, + }), + }); + + transactions = factories.collection.build({ + name: 'transactions', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + description: factories.columnSchema.build(), + amountInEur: factories.columnSchema.build(), + card: factories.manyToOneSchema.build({ + foreignCollection: 'cards', + foreignKey: 'card_id', + }), + user: factories.manyToOneSchema.build({ + foreignCollection: 'users', + foreignKey: 'user_id', + }), + }, + }), + }); + + dataSource = factories.dataSource.buildWithCollections([card, user, transactions]); + decoratedDataSource = new DataSourceDecorator(dataSource, LazyJoinDecorator); + decoratedTransactions = decoratedDataSource.getCollection('transactions'); + }); + + describe('when projection ask for foreign key only', () => { + test('it should not join', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card_id: 2 }]); + + const caller = factories.caller.build(); + const filter = factories.filter.build(); + + const records = await decoratedTransactions.list( + caller, + filter, + new Projection('id', 'card:id'), + ); + + expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id')); + expect(records).toStrictEqual([{ id: 1, card: { id: 2 } }]); + }); + + test('it should work with multiple relations', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card_id: 2, user_id: 3 }]); + + const caller = factories.caller.build(); + const filter = factories.filter.build(); + + const records = await decoratedTransactions.list( + caller, + filter, + new Projection('id', 'card:id', 'user:id'), + ); + + expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id', 'user_id')); + expect(records).toStrictEqual([{ id: 1, card: { id: 2 }, user: { id: 3 } }]); + }); + + test('it should disable join on projection but not in condition tree', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card_id: 2 }]); + + const caller = factories.caller.build(); + const filter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: 'card:type', + operator: 'Equal', + value: 'Visa', + }), + }); + + const records = await decoratedTransactions.list( + caller, + filter, + new Projection('id', 'card:id'), + ); + + expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id')); + expect(records).toStrictEqual([{ id: 1, card: { id: 2 } }]); + }); + }); + + describe('when projection ask for multiple fields in foreign collection', () => { + test('it should join', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card: { id: 2, type: 'Visa' } }]); + + const caller = factories.caller.build(); + const filter = factories.filter.build(); + const projection = new Projection('id', 'card:id', 'card:type'); + + const records = await decoratedTransactions.list(caller, filter, projection); + + expect(spy).toHaveBeenCalledWith(caller, filter, projection); + expect(records).toStrictEqual([{ id: 1, card: { id: 2, type: 'Visa' } }]); + }); + }); + + describe('when condition tree is on foreign key only', () => { + test('it should not join', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card: { id: 2, type: 'Visa' } }]); + + const caller = factories.caller.build(); + const projection = new Projection('id', 'card:id', 'card:type'); + const filter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: 'card:id', + operator: 'Equal', + value: '2', + }), + }); + + await decoratedTransactions.list(caller, filter, projection); + + const expectedFilter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: 'card_id', + operator: 'Equal', + value: '2', + }), + }); + + expect(spy).toHaveBeenCalledWith(caller, expectedFilter, projection); + }); + }); + + describe('when condition tree is on foreign collection fields', () => { + test('it should join', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card: { id: 2, type: 'Visa' } }]); + + const caller = factories.caller.build(); + const projection = new Projection('id', 'card:id', 'card:type'); + const filter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: 'card:type', + operator: 'Equal', + value: 'Visa', + }), + }); + + await decoratedTransactions.list(caller, filter, projection); + + expect(spy).toHaveBeenCalledWith(caller, filter, projection); + }); + }); + + test('it should correctly handle null relations on list', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card_id: null }]); + + const caller = factories.caller.build(); + const filter = factories.filter.build(); + + const records = await decoratedTransactions.list( + caller, + filter, + new Projection('id', 'card:id'), + ); + + expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id')); + expect(records).toStrictEqual([{ id: 1 }]); + }); +}); From ff1c0c48386f59ba71f54e43ab9da22b5d1420a4 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Mon, 13 Jan 2025 17:24:05 +0100 Subject: [PATCH 4/5] fix: test lint --- .../test/decorators/lazy-join/collection.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts b/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts index 2c94fd23c2..0a6509a8b0 100644 --- a/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts @@ -1,9 +1,7 @@ import { - Caller, Collection, DataSource, DataSourceDecorator, - PaginatedFilter, Projection, } from '@forestadmin/datasource-toolkit'; import * as factories from '@forestadmin/datasource-toolkit/dist/test/__factories__'; From 71ad7ac480c8e299d82dfb7b03deef400fcfd3dd Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Mon, 20 Jan 2025 11:52:01 +0100 Subject: [PATCH 5/5] feat: handle aggregate --- .../src/decorators/lazy-join/collection.ts | 108 +++--- .../decorators/lazy-join/collection.test.ts | 356 ++++++++++++------ 2 files changed, 312 insertions(+), 152 deletions(-) diff --git a/packages/datasource-customizer/src/decorators/lazy-join/collection.ts b/packages/datasource-customizer/src/decorators/lazy-join/collection.ts index 25088c0b79..6575cf113c 100644 --- a/packages/datasource-customizer/src/decorators/lazy-join/collection.ts +++ b/packages/datasource-customizer/src/decorators/lazy-join/collection.ts @@ -1,7 +1,10 @@ import { + AggregateResult, + Aggregation, Caller, CollectionDecorator, FieldSchema, + Filter, ManyToOneSchema, PaginatedFilter, Projection, @@ -14,12 +17,53 @@ export default class LazyJoinDecorator extends CollectionDecorator { filter: PaginatedFilter, projection: Projection, ): Promise { - const refinedProjection = this.refineProjection(projection); + const refinedProjection = projection.replace(field => this.refineField(field, projection)); const refinedFilter = await this.refineFilter(caller, filter); const records = await this.childCollection.list(caller, refinedFilter, refinedProjection); - return this.refineRecords(records, projection); + this.refineResults(projection, (relationName, foreignKey, foreignKeyTarget) => { + records.forEach(record => { + if (record[foreignKey]) { + record[relationName] = { [foreignKeyTarget]: record[foreignKey] }; + } + + delete record[foreignKey]; + }); + }); + + return records; + } + + override async aggregate( + caller: Caller, + filter: Filter, + aggregation: Aggregation, + limit?: number, + ): Promise { + const refinedAggregation = aggregation.replaceFields(field => + this.refineField(field, aggregation.projection), + ); + const refinedFilter = await this.refineFilter(caller, filter); + + const results = await this.childCollection.aggregate( + caller, + refinedFilter, + refinedAggregation, + limit, + ); + + this.refineResults(aggregation.projection, (relationName, foreignKey, foreignKeyTarget) => { + results.forEach(result => { + if (result.group[foreignKey]) { + result.group[`${relationName}:${foreignKeyTarget}`] = result.group[foreignKey]; + } + + delete result.group[foreignKey]; + }); + }); + + return results; } private isLazyRelationProjection(relation: FieldSchema, relationProjection: Projection) { @@ -30,67 +74,37 @@ export default class LazyJoinDecorator extends CollectionDecorator { ); } - private refineProjection(projection: Projection): Projection { - const newProjection = new Projection(...projection); + private refineField(field: string, projection: Projection): string { + const relationName = field.split(':')[0]; + const relation = this.schema.fields[relationName] as ManyToOneSchema; + const relationProjection = projection.relations[relationName]; - Object.entries(newProjection.relations).forEach(([relationName, relationProjection]) => { - const relation = this.schema.fields[relationName] as ManyToOneSchema; - - if (this.isLazyRelationProjection(relation, relationProjection)) { - const index = newProjection.findIndex(p => p.startsWith(relationName)); - - newProjection[index] = relation.foreignKey; - } - }); - - return newProjection; + return this.isLazyRelationProjection(relation, relationProjection) + ? relation.foreignKey + : field; } override async refineFilter(caller: Caller, filter: PaginatedFilter): Promise { if (filter.conditionTree) { - const relationToRefine: Record = Object.entries( - filter.conditionTree.projection.relations, - ).reduce((relations, [relationName, relationProjection]) => { - const relation = this.schema.fields[relationName] as ManyToOneSchema; - - if (this.isLazyRelationProjection(relation, relationProjection)) { - relations[relationName] = relation; - } - - return relations; - }, {}); - - filter.conditionTree.replaceLeafs(leaf => { - const relationName = Object.keys(leaf.projection.relations)[0]; - - if (relationName && relationToRefine[relationName]) { - leaf.field = relationToRefine[relationName].foreignKey; - } - - return leaf; - }); + filter.conditionTree = filter.conditionTree.replaceFields(field => + this.refineField(field, filter.conditionTree.projection), + ); } return filter; } - private refineRecords(records: RecordData[], projection: Projection): RecordData[] { + private refineResults( + projection: Projection, + handler: (relationName: string, foreignKey: string, foreignKeyTarget: string) => void, + ) { Object.entries(projection.relations).forEach(([relationName, relationProjection]) => { const relation = this.schema.fields[relationName] as ManyToOneSchema; if (this.isLazyRelationProjection(relation, relationProjection)) { const { foreignKeyTarget, foreignKey } = relation; - - records.forEach(record => { - if (record[foreignKey]) { - record[relationName] = { [foreignKeyTarget]: record[foreignKey] }; - } - - delete record[foreignKey]; - }); + handler(relationName, foreignKey, foreignKeyTarget); } }); - - return records; } } diff --git a/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts b/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts index 0a6509a8b0..44cc0fca46 100644 --- a/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts +++ b/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts @@ -1,4 +1,5 @@ import { + Aggregation, Collection, DataSource, DataSourceDecorator, @@ -60,145 +61,290 @@ describe('LazyJoinDecorator', () => { decoratedTransactions = decoratedDataSource.getCollection('transactions'); }); - describe('when projection ask for foreign key only', () => { - test('it should not join', async () => { - const spy = jest.spyOn(transactions, 'list'); - spy.mockResolvedValue([{ id: 1, card_id: 2 }]); + describe('list', () => { + describe('when projection ask for foreign key only', () => { + test('it should not join', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card_id: 2 }]); - const caller = factories.caller.build(); - const filter = factories.filter.build(); + const caller = factories.caller.build(); + const filter = factories.filter.build(); - const records = await decoratedTransactions.list( - caller, - filter, - new Projection('id', 'card:id'), - ); + const records = await decoratedTransactions.list( + caller, + filter, + new Projection('id', 'card:id'), + ); - expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id')); - expect(records).toStrictEqual([{ id: 1, card: { id: 2 } }]); - }); + expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id')); + expect(records).toStrictEqual([{ id: 1, card: { id: 2 } }]); + }); - test('it should work with multiple relations', async () => { - const spy = jest.spyOn(transactions, 'list'); - spy.mockResolvedValue([{ id: 1, card_id: 2, user_id: 3 }]); + test('it should work with multiple relations', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card_id: 2, user_id: 3 }]); + + const caller = factories.caller.build(); + const filter = factories.filter.build(); + + const records = await decoratedTransactions.list( + caller, + filter, + new Projection('id', 'card:id', 'user:id'), + ); + + expect(spy).toHaveBeenCalledWith( + caller, + filter, + new Projection('id', 'card_id', 'user_id'), + ); + expect(records).toStrictEqual([{ id: 1, card: { id: 2 }, user: { id: 3 } }]); + }); - const caller = factories.caller.build(); - const filter = factories.filter.build(); + test('it should disable join on projection but not in condition tree', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card_id: 2 }]); - const records = await decoratedTransactions.list( - caller, - filter, - new Projection('id', 'card:id', 'user:id'), - ); + const caller = factories.caller.build(); + const filter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: 'card:type', + operator: 'Equal', + value: 'Visa', + }), + }); + + const records = await decoratedTransactions.list( + caller, + filter, + new Projection('id', 'card:id'), + ); - expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id', 'user_id')); - expect(records).toStrictEqual([{ id: 1, card: { id: 2 }, user: { id: 3 } }]); + expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id')); + expect(records).toStrictEqual([{ id: 1, card: { id: 2 } }]); + }); }); - test('it should disable join on projection but not in condition tree', async () => { - const spy = jest.spyOn(transactions, 'list'); - spy.mockResolvedValue([{ id: 1, card_id: 2 }]); + describe('when projection ask for multiple fields in foreign collection', () => { + test('it should join', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card: { id: 2, type: 'Visa' } }]); - const caller = factories.caller.build(); - const filter = factories.filter.build({ - conditionTree: factories.conditionTreeLeaf.build({ - field: 'card:type', - operator: 'Equal', - value: 'Visa', - }), - }); + const caller = factories.caller.build(); + const filter = factories.filter.build(); + const projection = new Projection('id', 'card:id', 'card:type'); - const records = await decoratedTransactions.list( - caller, - filter, - new Projection('id', 'card:id'), - ); + const records = await decoratedTransactions.list(caller, filter, projection); - expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id')); - expect(records).toStrictEqual([{ id: 1, card: { id: 2 } }]); + expect(spy).toHaveBeenCalledWith(caller, filter, projection); + expect(records).toStrictEqual([{ id: 1, card: { id: 2, type: 'Visa' } }]); + }); }); - }); - describe('when projection ask for multiple fields in foreign collection', () => { - test('it should join', async () => { - const spy = jest.spyOn(transactions, 'list'); - spy.mockResolvedValue([{ id: 1, card: { id: 2, type: 'Visa' } }]); + describe('when condition tree is on foreign key only', () => { + test('it should not join', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card: { id: 2, type: 'Visa' } }]); + + const caller = factories.caller.build(); + const projection = new Projection('id', 'card:id', 'card:type'); + const filter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: 'card:id', + operator: 'Equal', + value: '2', + }), + }); - const caller = factories.caller.build(); - const filter = factories.filter.build(); - const projection = new Projection('id', 'card:id', 'card:type'); + await decoratedTransactions.list(caller, filter, projection); - const records = await decoratedTransactions.list(caller, filter, projection); + const expectedFilter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: 'card_id', + operator: 'Equal', + value: '2', + }), + }); - expect(spy).toHaveBeenCalledWith(caller, filter, projection); - expect(records).toStrictEqual([{ id: 1, card: { id: 2, type: 'Visa' } }]); + expect(spy).toHaveBeenCalledWith(caller, expectedFilter, projection); + }); }); - }); - describe('when condition tree is on foreign key only', () => { - test('it should not join', async () => { - const spy = jest.spyOn(transactions, 'list'); - spy.mockResolvedValue([{ id: 1, card: { id: 2, type: 'Visa' } }]); - - const caller = factories.caller.build(); - const projection = new Projection('id', 'card:id', 'card:type'); - const filter = factories.filter.build({ - conditionTree: factories.conditionTreeLeaf.build({ - field: 'card:id', - operator: 'Equal', - value: '2', - }), - }); + describe('when condition tree is on foreign collection fields', () => { + test('it should join', async () => { + const spy = jest.spyOn(transactions, 'list'); + spy.mockResolvedValue([{ id: 1, card: { id: 2, type: 'Visa' } }]); + + const caller = factories.caller.build(); + const projection = new Projection('id', 'card:id', 'card:type'); + const filter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: 'card:type', + operator: 'Equal', + value: 'Visa', + }), + }); - await decoratedTransactions.list(caller, filter, projection); + await decoratedTransactions.list(caller, filter, projection); - const expectedFilter = factories.filter.build({ - conditionTree: factories.conditionTreeLeaf.build({ - field: 'card_id', - operator: 'Equal', - value: '2', - }), + expect(spy).toHaveBeenCalledWith(caller, filter, projection); }); - - expect(spy).toHaveBeenCalledWith(caller, expectedFilter, projection); }); - }); - describe('when condition tree is on foreign collection fields', () => { - test('it should join', async () => { + test('it should correctly handle null relations', async () => { const spy = jest.spyOn(transactions, 'list'); - spy.mockResolvedValue([{ id: 1, card: { id: 2, type: 'Visa' } }]); + spy.mockResolvedValue([{ id: 1, card_id: null }]); const caller = factories.caller.build(); - const projection = new Projection('id', 'card:id', 'card:type'); - const filter = factories.filter.build({ - conditionTree: factories.conditionTreeLeaf.build({ - field: 'card:type', - operator: 'Equal', - value: 'Visa', - }), - }); + const filter = factories.filter.build(); - await decoratedTransactions.list(caller, filter, projection); + const records = await decoratedTransactions.list( + caller, + filter, + new Projection('id', 'card:id'), + ); - expect(spy).toHaveBeenCalledWith(caller, filter, projection); + expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id')); + expect(records).toStrictEqual([{ id: 1 }]); }); }); - test('it should correctly handle null relations on list', async () => { - const spy = jest.spyOn(transactions, 'list'); - spy.mockResolvedValue([{ id: 1, card_id: null }]); + describe('aggregate', () => { + describe('when group by foreign pk', () => { + test('it should not join', async () => { + const spy = jest.spyOn(transactions, 'aggregate'); + spy.mockResolvedValue([ + { value: 1824.11, group: { user_id: 1 } }, + { value: 824, group: { user_id: 2 } }, + ]); + + const caller = factories.caller.build(); + const filter = factories.filter.build(); + + const results = await decoratedTransactions.aggregate( + caller, + filter, + new Aggregation({ + operation: 'Sum', + field: 'amountInEur', + groups: [{ field: 'user:id' }], + }), + 1, + ); + + expect(spy).toHaveBeenCalledWith( + caller, + filter, + new Aggregation({ + operation: 'Sum', + field: 'amountInEur', + groups: [{ field: 'user_id' }], + }), + 1, + ); + expect(results).toStrictEqual([ + { value: 1824.11, group: { 'user:id': 1 } }, + { value: 824, group: { 'user:id': 2 } }, + ]); + }); + }); - const caller = factories.caller.build(); - const filter = factories.filter.build(); + describe('when group by foreign field', () => { + test('it should join', async () => { + const spy = jest.spyOn(transactions, 'aggregate'); + spy.mockResolvedValue([ + { value: 1824.11, group: { 'user:name': 'Brad' } }, + { value: 824, group: { 'user:name': 'Pit' } }, + ]); + + const caller = factories.caller.build(); + const filter = factories.filter.build(); + const aggregation = new Aggregation({ + operation: 'Sum', + field: 'amountInEur', + groups: [{ field: 'user:name' }], + }); + + const results = await decoratedTransactions.aggregate(caller, filter, aggregation, 1); + + expect(spy).toHaveBeenCalledWith(caller, filter, aggregation, 1); + expect(results).toStrictEqual([ + { value: 1824.11, group: { 'user:name': 'Brad' } }, + { value: 824, group: { 'user:name': 'Pit' } }, + ]); + }); + }); - const records = await decoratedTransactions.list( - caller, - filter, - new Projection('id', 'card:id'), - ); + describe('when filter on foreign pk', () => { + test('it should not join', async () => { + const spy = jest.spyOn(transactions, 'aggregate'); + spy.mockResolvedValue([ + { value: 1824.11, group: { 'user:name': 'Brad' } }, + { value: 824, group: { 'user:name': 'Pit' } }, + ]); + + const caller = factories.caller.build(); + const filter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: 'card:id', + operator: 'Equal', + value: 1, + }), + }); + const aggregation = new Aggregation({ + operation: 'Sum', + field: 'amountInEur', + groups: [{ field: 'user:name' }], + }); + + const results = await decoratedTransactions.aggregate(caller, filter, aggregation, 1); + + const expectedFilter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: 'card_id', + operator: 'Equal', + value: 1, + }), + }); + + expect(spy).toHaveBeenCalledWith(caller, expectedFilter, aggregation, 1); + expect(results).toStrictEqual([ + { value: 1824.11, group: { 'user:name': 'Brad' } }, + { value: 824, group: { 'user:name': 'Pit' } }, + ]); + }); + }); - expect(spy).toHaveBeenCalledWith(caller, filter, new Projection('id', 'card_id')); - expect(records).toStrictEqual([{ id: 1 }]); + describe('when filter on foreign field', () => { + test('it should join', async () => { + const spy = jest.spyOn(transactions, 'aggregate'); + spy.mockResolvedValue([ + { value: 1824.11, group: { 'user:name': 'Brad' } }, + { value: 824, group: { 'user:name': 'Pit' } }, + ]); + + const caller = factories.caller.build(); + const filter = factories.filter.build({ + conditionTree: factories.conditionTreeLeaf.build({ + field: 'card:type', + operator: 'Equal', + value: 'Visa', + }), + }); + const aggregation = new Aggregation({ + operation: 'Sum', + field: 'amountInEur', + groups: [{ field: 'user:name' }], + }); + + const results = await decoratedTransactions.aggregate(caller, filter, aggregation, 1); + + expect(spy).toHaveBeenCalledWith(caller, filter, aggregation, 1); + expect(results).toStrictEqual([ + { value: 1824.11, group: { 'user:name': 'Brad' } }, + { value: 824, group: { 'user:name': 'Pit' } }, + ]); + }); + }); }); });