From 1b6b248321944b67a71e1ce4f42ee0d3932ebd69 Mon Sep 17 00:00:00 2001 From: Arnaud MONCEL Date: Thu, 23 Jan 2025 11:13:32 +0100 Subject: [PATCH] feat: add lazy join decorator (#1240) --- .../src/decorators/decorators-stack.ts | 2 + .../src/decorators/lazy-join/collection.ts | 110 ++++++ .../decorators/lazy-join/collection.test.ts | 350 ++++++++++++++++++ 3 files changed, 462 insertions(+) create mode 100644 packages/datasource-customizer/src/decorators/lazy-join/collection.ts create mode 100644 packages/datasource-customizer/test/decorators/lazy-join/collection.test.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..6575cf113c --- /dev/null +++ b/packages/datasource-customizer/src/decorators/lazy-join/collection.ts @@ -0,0 +1,110 @@ +import { + AggregateResult, + Aggregation, + Caller, + CollectionDecorator, + FieldSchema, + Filter, + 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 = projection.replace(field => this.refineField(field, projection)); + const refinedFilter = await this.refineFilter(caller, filter); + + const records = await this.childCollection.list(caller, refinedFilter, refinedProjection); + + 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) { + return ( + relation.type === 'ManyToOne' && + relationProjection.length === 1 && + relationProjection[0] === relation.foreignKeyTarget + ); + } + + 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]; + + return this.isLazyRelationProjection(relation, relationProjection) + ? relation.foreignKey + : field; + } + + override async refineFilter(caller: Caller, filter: PaginatedFilter): Promise { + if (filter.conditionTree) { + filter.conditionTree = filter.conditionTree.replaceFields(field => + this.refineField(field, filter.conditionTree.projection), + ); + } + + return filter; + } + + 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; + handler(relationName, foreignKey, foreignKeyTarget); + } + }); + } +} 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..44cc0fca46 --- /dev/null +++ b/packages/datasource-customizer/test/decorators/lazy-join/collection.test.ts @@ -0,0 +1,350 @@ +import { + Aggregation, + Collection, + DataSource, + DataSourceDecorator, + 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('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 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', 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 }]); + }); + }); + + 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 } }, + ]); + }); + }); + + 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' } }, + ]); + }); + }); + + 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' } }, + ]); + }); + }); + + 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' } }, + ]); + }); + }); + }); +});