diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index af94710a89..3d2bcaae0d 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -196,11 +196,11 @@ export default class Agent extends FrameworkMounter const { isProduction, logger, typingsPath, typingsMaxDepth } = this.options; // It allows to rebuild the full customization stack with no code customizations - this.nocodeCustomizer = new DataSourceCustomizer(); - this.nocodeCustomizer.addDataSource(this.customizer.getFactory()); - this.nocodeCustomizer.use(this.customizationService.addCustomizations); + // this.nocodeCustomizer = new DataSourceCustomizer(); + // this.nocodeCustomizer.addDataSource(this.customizer.getFactory()); + // this.nocodeCustomizer.use(this.customizationService.addCustomizations); - const dataSource = await this.nocodeCustomizer.getDataSource(logger); + const dataSource = await this.customizer.getDataSource(logger); const [router] = await Promise.all([ this.getRouter(dataSource), this.sendSchema(dataSource), diff --git a/packages/datasource-customizer/src/collection-customizer.ts b/packages/datasource-customizer/src/collection-customizer.ts index b49640c220..4fe4dcf554 100644 --- a/packages/datasource-customizer/src/collection-customizer.ts +++ b/packages/datasource-customizer/src/collection-customizer.ts @@ -10,12 +10,15 @@ import DataSourceCustomizer from './datasource-customizer'; import { ActionDefinition } from './decorators/actions/types/actions'; import { BinaryMode } from './decorators/binary/types'; import { CollectionChartDefinition } from './decorators/chart/types'; -import { ComputedDefinition, DeprecatedComputedDefinition } from './decorators/computed/types'; +import { + ComputedDefinition, + DeprecatedComputedDefinition, + RelationDefinition, +} from './decorators/computed/types'; import mapDeprecated from './decorators/computed/utils/map-deprecated'; import DecoratorsStack from './decorators/decorators-stack'; import { HookHandler, HookPosition, HookType, HooksContext } from './decorators/hook/types'; import { OperatorDefinition } from './decorators/operators-emulate/types'; -import { RelationDefinition } from './decorators/relation/types'; import { SearchDefinition } from './decorators/search/types'; import { SegmentDefinition } from './decorators/segment/types'; import { WriteDefinition } from './decorators/write/write-replace/types'; @@ -176,8 +179,8 @@ export default class CollectionCustomizer< definition: DeprecatedComputedDefinition | ComputedDefinition, ): this => { return this.pushCustomization(async () => { - const collectionBeforeRelations = this.stack.earlyComputed.getCollection(this.name); - const collectionAfterRelations = this.stack.lateComputed.getCollection(this.name); + const collectionBeforeRelations = this.stack.computed.getCollection(this.name); + const collectionAfterRelations = this.stack.computed.getCollection(this.name); const canBeComputedBeforeRelations = definition.dependencies.every(field => { try { return !!CollectionUtils.getFieldSchema(collectionBeforeRelations, field); @@ -417,7 +420,7 @@ export default class CollectionCustomizer< */ emulateFieldFiltering(name: TColumnName): this { return this.pushCustomization(async () => { - const collection = this.stack.lateOpEmulate.getCollection(this.name); + const collection = this.stack.computed.getCollection(this.name); const field = collection.schema.fields[name] as ColumnSchema; if (typeof field.columnType === 'string') { @@ -443,9 +446,7 @@ export default class CollectionCustomizer< */ emulateFieldOperator(name: TColumnName, operator: Operator): this { return this.pushCustomization(async () => { - const collection = this.stack.earlyOpEmulate.getCollection(this.name).schema.fields[name] - ? this.stack.earlyOpEmulate.getCollection(this.name) - : this.stack.lateOpEmulate.getCollection(this.name); + const collection = this.stack.opEmulate.getCollection(this.name); collection.emulateFieldOperator(name, operator); }); @@ -499,9 +500,7 @@ export default class CollectionCustomizer< replacer: OperatorDefinition, ): this { return this.pushCustomization(async () => { - const collection = this.stack.earlyOpEmulate.getCollection(this.name).schema.fields[name] - ? this.stack.earlyOpEmulate.getCollection(this.name) - : this.stack.lateOpEmulate.getCollection(this.name); + const collection = this.stack.opEmulate.getCollection(this.name); collection.replaceFieldOperator(name, operator, replacer as OperatorDefinition); }); @@ -547,7 +546,7 @@ export default class CollectionCustomizer< private pushRelation(name: string, definition: RelationDefinition): this { return this.pushCustomization(async () => { - this.stack.relation.getCollection(this.name).addRelation(name, definition); + this.stack.computed.getCollection(this.name).addRelation(name, definition); }); } diff --git a/packages/datasource-customizer/src/decorators/computed/collection.ts b/packages/datasource-customizer/src/decorators/computed/collection.ts index c6805499da..8772c7137e 100644 --- a/packages/datasource-customizer/src/decorators/computed/collection.ts +++ b/packages/datasource-customizer/src/decorators/computed/collection.ts @@ -1,40 +1,54 @@ +// eslint-disable-next-line max-classes-per-file import { AggregateResult, Aggregation, Caller, + Collection, CollectionDecorator, CollectionSchema, + ColumnSchema, + ConditionTree, + ConditionTreeLeaf, DataSourceDecorator, FieldValidator, Filter, PaginatedFilter, Projection, RecordData, + RecordUtils, RelationSchema, + SchemaUtils, } from '@forestadmin/datasource-toolkit'; +import { resolve } from 'path'; import computeFromRecords from './helpers/compute-fields'; -import rewriteField from './helpers/rewrite-projection'; -import { ComputedDefinition } from './types'; +import { ComputedDefinition, RelationDefinition } from './types'; import CollectionCustomizationContext from '../../context/collection-context'; -/** Decorator injects computed fields */ -export default class ComputedCollection extends CollectionDecorator { - override readonly dataSource: DataSourceDecorator; - protected computeds: Record = {}; +class MapWithDefault extends Map { + default: () => V; - /** @internal */ - getComputed(path: string): ComputedDefinition { - const index = path.indexOf(':'); - if (index === -1) return this.computeds[path]; + constructor(defaultFunction) { + super(); + this.default = defaultFunction; + } - const { foreignCollection } = this.schema.fields[path.substring(0, index)] as RelationSchema; - const association = this.dataSource.getCollection(foreignCollection); + override get(key) { + if (!this.has(key)) { + this.set(key, this.default()); + } - return association.getComputed(path.substring(index + 1)); + return super.get(key); } +} - registerComputed(name: string, computed: ComputedDefinition): void { +/** Decorator injects computed fields */ +export default class ComputedCollection extends CollectionDecorator { + override readonly dataSource: DataSourceDecorator; + protected computeds: Record = {}; + protected relations: Record = {}; + + public registerComputed(name: string, computed: ComputedDefinition): void { FieldValidator.validateName(this.name, name); // Check that all dependencies exist and are columns @@ -50,18 +64,43 @@ export default class ComputedCollection extends CollectionDecorator { this.markSchemaAsDirty(); } + public addRelation(name: string, partialJoint: RelationDefinition): void { + const relation = this.relationWithOptionalFields(partialJoint); + this.checkForeignKeys(relation); + this.checkOriginKeys(relation); + + this.relations[name] = relation; + this.markSchemaAsDirty(); + } + override async list( caller: Caller, filter: PaginatedFilter, projection: Projection, ): Promise { - const childProjection = projection.replace(path => rewriteField(this, path)); - const records = await this.childCollection.list(caller, filter, childProjection); - if (childProjection.equals(projection)) return records; + try { + const dependencyMap = new MapWithDefault; resolve: any }>( + () => ({ dependencies: new Set(), resolve: {} }), + ); + // Build the dependency tree for this projection + this.buildTree(projection, dependencyMap); + + const datasourceProjection = new Projection(); - const context = new CollectionCustomizationContext(this, caller); + for (const [fieldName, resolver] of dependencyMap.entries()) { + if (resolver.dependencies.size === 1 && resolver.dependencies.has(fieldName)) + datasourceProjection.push(fieldName); + } - return computeFromRecords(context, this, childProjection, projection, records); + const records = await this.childCollection.list(caller, filter, datasourceProjection); + if (datasourceProjection.equals(projection)) return records; + + const context = new CollectionCustomizationContext(this, caller); + + return await computeFromRecords(context, this, datasourceProjection, projection, records); + } catch (err) { + console.error(err); + } } override async aggregate( @@ -70,8 +109,12 @@ export default class ComputedCollection extends CollectionDecorator { aggregation: Aggregation, limit?: number, ): Promise { - // No computed are used in the aggregation => just delegate to the underlying collection. - if (!aggregation.projection.some(field => this.getComputed(field))) { + // No computed are used in the aggregation => just delegate to the underlying DS. + // No emulated relations are used in the aggregation => just delegate to the underlying DS. + if ( + !aggregation.projection.some(field => this.getComputed(field)) && + Object.keys(aggregation.projection.relations).every(prefix => !this.relations[prefix]) + ) { return this.childCollection.aggregate(caller, filter, aggregation, limit); } @@ -99,6 +142,311 @@ export default class ComputedCollection extends CollectionDecorator { }; } + for (const [name, relation] of Object.entries(this.relations)) { + schema.fields[name] = relation; + } + return schema; } + + protected override async refineFilter( + caller: Caller, + filter: PaginatedFilter, + ): Promise { + return filter?.override({ + conditionTree: await filter.conditionTree?.replaceLeafsAsync( + leaf => this.rewriteLeaf(caller, leaf), + this, + ), + + // Replace sort in emulated relations to + // - sorting by the fk of the relation for many to one + // - removing the sort altogether for one to one + // + // This is far from ideal, but the best that can be done without taking a major + // performance hit. + // Customers which want proper sorting should enable emulation in the associated + // middleware + sort: filter.sort?.replaceClauses(clause => + this.rewriteField(clause.field).map(field => ({ ...clause, field })), + ), + }); + } + + private buildTree( + projections: Projection, + dependencyMap: MapWithDefault; resolve: any }>, + ) { + // TODO look at projection.columns projection.relations + + const dependencyTree = projections.reduce((acc, projection) => { + // avoid recompute dependencies + if (acc.has(projection)) return acc; + + // A relation + if (projection.includes(':')) { + const relationName = projection.split(':').shift(); + const currentRelationResolver = acc.get(relationName); + + const schema = this.schema.fields[relationName] as RelationSchema; + + // Is it computed ? + if (this.relations[relationName]) { + // dependencies due to keys + switch (schema.type) { + case 'OneToOne': + case 'OneToMany': + currentRelationResolver.dependencies.add(schema.originKeyTarget); + break; + + case 'ManyToOne': + default: + currentRelationResolver.dependencies.add(schema.foreignKey); + } + + this.buildTree([...currentRelationResolver.dependencies] as Projection, dependencyMap); + + currentRelationResolver.projection.push(projection.split(':')[0]); + + // What about projection sub Projections ??? projection.relations[relationName] + // I know that not this collection but still we could query computed yes and it will be handle by his ComputedCollection. + } + // Or native + else { + currentRelationResolver.dependencies.add(projection); + // What about projection sub Projections ??? projection.relations[relationName] + // I know that not this collection but still we could query computed yes and it will be handle by his ComputedCollection. + } + } + // A field + else { + const currentFieldResolver = acc.get(projection); + + // Is it computed ? + if (this.computeds[projection]) { + this.computeds[projection].dependencies.forEach(item => + currentFieldResolver.dependencies.add(item), + ); + + this.buildTree(this.computeds[projection].dependencies as Projection, dependencyMap); + } + // Or native + else { + currentFieldResolver.dependencies.add(projection); + } + } + + return acc; + }, dependencyMap); + + return dependencyTree; + } + + /** @internal */ + getComputed(path: string): ComputedDefinition { + const index = path.indexOf(':'); + if (index === -1) return this.computeds[path]; + + const { foreignCollection } = this.schema.fields[path.substring(0, index)] as RelationSchema; + const association = this.dataSource.getCollection(foreignCollection); + + return association.getComputed(path.substring(index + 1)); + } + + // ---- Relations definition validation + + private relationWithOptionalFields(partialJoint: RelationDefinition): RelationSchema { + const relation = { ...partialJoint }; + const target = this.dataSource.getCollection(relation.foreignCollection); + + if (relation.type === 'ManyToOne') { + relation.foreignKeyTarget ??= SchemaUtils.getPrimaryKeys(target.schema)[0]; + } else if (relation.type === 'OneToOne' || relation.type === 'OneToMany') { + relation.originKeyTarget ??= SchemaUtils.getPrimaryKeys(this.schema)[0]; + } else if (relation.type === 'ManyToMany') { + relation.originKeyTarget ??= SchemaUtils.getPrimaryKeys(this.schema)[0]; + relation.foreignKeyTarget ??= SchemaUtils.getPrimaryKeys(target.schema)[0]; + } + + return relation as RelationSchema; + } + + private checkForeignKeys(relation: RelationSchema): void { + if (relation.type === 'ManyToOne' || relation.type === 'ManyToMany') { + this.checkKeys( + relation.type === 'ManyToMany' + ? this.dataSource.getCollection(relation.throughCollection) + : this, + this.dataSource.getCollection(relation.foreignCollection), + relation.foreignKey, + relation.foreignKeyTarget, + ); + } + } + + private checkOriginKeys(relation: RelationSchema): void { + if ( + relation.type === 'OneToMany' || + relation.type === 'OneToOne' || + relation.type === 'ManyToMany' + ) { + this.checkKeys( + relation.type === 'ManyToMany' + ? this.dataSource.getCollection(relation.throughCollection) + : this.dataSource.getCollection(relation.foreignCollection), + this, + relation.originKey, + relation.originKeyTarget, + ); + } + } + + private checkKeys( + owner: Collection, + targetOwner: Collection, + keyName: string, + targetName: string, + ): void { + this.checkColumn(owner, keyName); + this.checkColumn(targetOwner, targetName); + + const key = owner.schema.fields[keyName] as ColumnSchema; + const target = targetOwner.schema.fields[targetName] as ColumnSchema; + + if (key.columnType !== target.columnType) { + throw new Error( + `Types from '${owner.name}.${keyName}' and ` + + `'${targetOwner.name}.${targetName}' do not match.`, + ); + } + } + + private checkColumn(owner: Collection, name: string): void { + const column = owner.schema.fields[name]; + + if (!column || column.type !== 'Column') { + throw new Error(`Column not found: '${owner.name}.${name}'`); + } + + // Pitrerie + if (!column.filterOperators?.has('In') && !this.getComputed(name)) { + throw new Error(`Column does not support the In operator: '${owner.name}.${name}'`); + } + } + + // Filtering + + private rewriteField(field: string): string[] { + const prefix = field.split(':').shift(); + const schema = this.schema.fields[prefix]; + if (schema.type === 'Column') return [field]; + + const relation = this.dataSource.getCollection(schema.foreignCollection); + let result = [] as string[]; + + if (!this.relations[prefix]) { + result = relation + .rewriteField(field.substring(prefix.length + 1)) + .map(subField => `${prefix}:${subField}`); + } else if (schema.type === 'ManyToOne') { + result = [schema.foreignKey]; + } else if ( + schema.type === 'OneToOne' || + schema.type === 'OneToMany' || + schema.type === 'ManyToMany' + ) { + result = [schema.originKeyTarget]; + } + + return result; + } + + private async rewriteLeaf(caller: Caller, leaf: ConditionTreeLeaf): Promise { + const prefix = leaf.field.split(':').shift(); + const schema = this.schema.fields[prefix]; + if (schema.type === 'Column') return leaf; + + const relation = this.dataSource.getCollection(schema.foreignCollection); + let result = leaf as ConditionTree; + + if (!this.relations[prefix]) { + result = (await relation.rewriteLeaf(caller, leaf.unnest())).nest(prefix); + } else if (schema.type === 'ManyToOne') { + const records = await relation.list( + caller, + new Filter({ conditionTree: leaf.unnest() }), + new Projection(schema.foreignKeyTarget), + ); + + result = new ConditionTreeLeaf(schema.foreignKey, 'In', [ + ...new Set( + records + .map(record => RecordUtils.getFieldValue(record, schema.foreignKeyTarget)) + .filter(v => v !== null), + ), + ]); + } else if (schema.type === 'OneToOne') { + const records = await relation.list( + caller, + new Filter({ conditionTree: leaf.unnest() }), + new Projection(schema.originKey), + ); + + result = new ConditionTreeLeaf(schema.originKeyTarget, 'In', [ + ...new Set( + records + .map(record => RecordUtils.getFieldValue(record, schema.originKey)) + .filter(v => v !== null), + ), + ]); + } + + return result; + } + + private async relationResolver( + caller: Caller, + records: RecordData[], + name: string, + projection: Projection, + ): Promise { + const schema = this.schema.fields[name] as RelationSchema; + const association = this.dataSource.getCollection(schema.foreignCollection); + + if (!this.relations[name]) { + console.log('WHY AM I EVEN HERE ???'); + } else if (schema.type === 'ManyToOne') { + const ids = records.map(record => record[schema.foreignKey]).filter(fk => fk !== null); + const subFilter = new Filter({ + conditionTree: new ConditionTreeLeaf(schema.foreignKeyTarget, 'In', [...new Set(ids)]), + }); + const subRecords = await association.list( + caller, + subFilter, + projection.union([schema.foreignKeyTarget]), + ); + + for (const record of records) { + record[name] = subRecords.find( + sr => sr[schema.foreignKeyTarget] === record[schema.foreignKey], + ); + } + } else if (schema.type === 'OneToOne' || schema.type === 'OneToMany') { + const ids = records.map(record => record[schema.originKeyTarget]).filter(okt => okt !== null); + const subFilter = new Filter({ + conditionTree: new ConditionTreeLeaf(schema.originKey, 'In', [...new Set(ids)]), + }); + const subRecords = await association.list( + caller, + subFilter, + projection.union([schema.originKey]), + ); + + for (const record of records) { + record[name] = subRecords.find( + sr => sr[schema.originKey] === record[schema.originKeyTarget], + ); + } + } + } } diff --git a/packages/datasource-customizer/src/decorators/computed/types.ts b/packages/datasource-customizer/src/decorators/computed/types.ts index 0ab7d35027..99e199e98c 100644 --- a/packages/datasource-customizer/src/decorators/computed/types.ts +++ b/packages/datasource-customizer/src/decorators/computed/types.ts @@ -1,4 +1,10 @@ -import { ColumnType } from '@forestadmin/datasource-toolkit'; +import { + ColumnType, + ManyToManySchema, + ManyToOneSchema, + OneToManySchema, + OneToOneSchema, +} from '@forestadmin/datasource-toolkit'; import CollectionCustomizationContext from '../../context/collection-context'; import { TCollectionName, TFieldName, TRow, TSchema } from '../../templates'; @@ -24,3 +30,10 @@ export interface DeprecatedComputedDefinition< > extends Omit, 'columnType'> { readonly columnType: 'Timeonly'; } + +type PartialBy> = Omit & Partial>; + +export type RelationDefinition = + | PartialBy + | PartialBy + | PartialBy; diff --git a/packages/datasource-customizer/src/decorators/decorators-stack.ts b/packages/datasource-customizer/src/decorators/decorators-stack.ts index ca88ca593d..6f3ef057c0 100644 --- a/packages/datasource-customizer/src/decorators/decorators-stack.ts +++ b/packages/datasource-customizer/src/decorators/decorators-stack.ts @@ -9,7 +9,6 @@ import HookCollectionDecorator from './hook/collection'; import OperatorsEmulateCollectionDecorator from './operators-emulate/collection'; import OperatorsEquivalenceCollectionDecorator from './operators-equivalence/collection'; import PublicationDataSourceDecorator from './publication/datasource'; -import RelationCollectionDecorator from './relation/collection'; import RenameFieldCollectionDecorator from './rename-field/collection'; import SchemaCollectionDecorator from './schema/collection'; import SearchCollectionDecorator from './search/collection'; @@ -22,13 +21,10 @@ export default class DecoratorsStack { action: DataSourceDecorator; binary: DataSourceDecorator; chart: ChartDataSourceDecorator; - earlyComputed: DataSourceDecorator; - earlyOpEmulate: DataSourceDecorator; + computed: DataSourceDecorator; + opEmulate: DataSourceDecorator; hook: DataSourceDecorator; - lateComputed: DataSourceDecorator; - lateOpEmulate: DataSourceDecorator; publication: PublicationDataSourceDecorator; - relation: DataSourceDecorator; renameField: DataSourceDecorator; schema: DataSourceDecorator; search: DataSourceDecorator; @@ -48,15 +44,10 @@ export default class DecoratorsStack { last = new DataSourceDecorator(last, EmptyCollectionDecorator); last = new DataSourceDecorator(last, OperatorsEquivalenceCollectionDecorator); - // Step 1: Computed-Relation-Computed sandwich (needed because some emulated relations depend - // on computed fields, and some computed fields depend on relation...) - // Note that replacement goes before emulation, as replacements may use emulated operators. - last = this.earlyComputed = new DataSourceDecorator(last, ComputedCollectionDecorator); - last = this.earlyOpEmulate = new DataSourceDecorator(last, OperatorsEmulateCollectionDecorator); - last = new DataSourceDecorator(last, OperatorsEquivalenceCollectionDecorator); - last = this.relation = new DataSourceDecorator(last, RelationCollectionDecorator); - last = this.lateComputed = new DataSourceDecorator(last, ComputedCollectionDecorator); - last = this.lateOpEmulate = new DataSourceDecorator(last, OperatorsEmulateCollectionDecorator); + // Step 1: Computed Field and Relation (enhance data) + last = this.computed = new DataSourceDecorator(last, ComputedCollectionDecorator); + last = this.opEmulate = new DataSourceDecorator(last, OperatorsEmulateCollectionDecorator); + last = new DataSourceDecorator(last, OperatorsEquivalenceCollectionDecorator); // Step 2: Those need access to all fields. They can be loaded in any order. diff --git a/packages/datasource-customizer/src/decorators/relation/collection.ts b/packages/datasource-customizer/src/decorators/relation/collection.ts deleted file mode 100644 index ecaad1761b..0000000000 --- a/packages/datasource-customizer/src/decorators/relation/collection.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { - AggregateResult, - Aggregation, - Caller, - Collection, - CollectionDecorator, - CollectionSchema, - ColumnSchema, - ConditionTree, - ConditionTreeLeaf, - DataSourceDecorator, - Filter, - PaginatedFilter, - Projection, - RecordData, - RecordUtils, - RelationSchema, - SchemaUtils, -} from '@forestadmin/datasource-toolkit'; - -import { RelationDefinition } from './types'; - -export default class RelationCollectionDecorator extends CollectionDecorator { - override readonly dataSource: DataSourceDecorator; - protected relations: Record = {}; - - addRelation(name: string, partialJoint: RelationDefinition): void { - const relation = this.relationWithOptionalFields(partialJoint); - this.checkForeignKeys(relation); - this.checkOriginKeys(relation); - - this.relations[name] = relation; - this.markSchemaAsDirty(); - } - - override async list( - caller: Caller, - filter: PaginatedFilter, - projection: Projection, - ): Promise { - const newFilter = await this.refineFilter(caller, filter); - const newProjection = projection.replace(this.rewriteField, this).withPks(this); - const records = await this.childCollection.list(caller, newFilter, newProjection); - if (newProjection.equals(projection)) return records; - - await this.reprojectInPlace(caller, records, projection); - - return projection.apply(records); - } - - override async aggregate( - caller: Caller, - filter: Filter, - aggregation: Aggregation, - limit?: number, - ): Promise { - const newFilter = await this.refineFilter(caller, filter); - - // No emulated relations are used in the aggregation - if (Object.keys(aggregation.projection.relations).every(prefix => !this.relations[prefix])) { - return this.childCollection.aggregate(caller, newFilter, aggregation, limit); - } - - // Fallback to full emulation. - return aggregation.apply( - await this.list(caller, filter, aggregation.projection), - caller.timezone, - limit, - ); - } - - protected override refineSchema(subSchema: CollectionSchema): CollectionSchema { - const schema = { ...subSchema, fields: { ...subSchema.fields } }; - - for (const [name, relation] of Object.entries(this.relations)) { - schema.fields[name] = relation; - } - - return schema; - } - - protected override async refineFilter( - caller: Caller, - filter: PaginatedFilter, - ): Promise { - return filter?.override({ - conditionTree: await filter.conditionTree?.replaceLeafsAsync( - leaf => this.rewriteLeaf(caller, leaf), - this, - ), - - // Replace sort in emulated relations to - // - sorting by the fk of the relation for many to one - // - removing the sort altogether for one to one - // - // This is far from ideal, but the best that can be done without taking a major - // performance hit. - // Customers which want proper sorting should enable emulation in the associated - // middleware - sort: filter.sort?.replaceClauses(clause => - this.rewriteField(clause.field).map(field => ({ ...clause, field })), - ), - }); - } - - private relationWithOptionalFields(partialJoint: RelationDefinition): RelationSchema { - const relation = { ...partialJoint }; - const target = this.dataSource.getCollection(relation.foreignCollection); - - if (relation.type === 'ManyToOne') { - relation.foreignKeyTarget ??= SchemaUtils.getPrimaryKeys(target.schema)[0]; - } else if (relation.type === 'OneToOne' || relation.type === 'OneToMany') { - relation.originKeyTarget ??= SchemaUtils.getPrimaryKeys(this.schema)[0]; - } else if (relation.type === 'ManyToMany') { - relation.originKeyTarget ??= SchemaUtils.getPrimaryKeys(this.schema)[0]; - relation.foreignKeyTarget ??= SchemaUtils.getPrimaryKeys(target.schema)[0]; - } - - return relation as RelationSchema; - } - - private checkForeignKeys(relation: RelationSchema): void { - if (relation.type === 'ManyToOne' || relation.type === 'ManyToMany') { - RelationCollectionDecorator.checkKeys( - relation.type === 'ManyToMany' - ? this.dataSource.getCollection(relation.throughCollection) - : this, - this.dataSource.getCollection(relation.foreignCollection), - relation.foreignKey, - relation.foreignKeyTarget, - ); - } - } - - private checkOriginKeys(relation: RelationSchema): void { - if ( - relation.type === 'OneToMany' || - relation.type === 'OneToOne' || - relation.type === 'ManyToMany' - ) { - RelationCollectionDecorator.checkKeys( - relation.type === 'ManyToMany' - ? this.dataSource.getCollection(relation.throughCollection) - : this.dataSource.getCollection(relation.foreignCollection), - this, - relation.originKey, - relation.originKeyTarget, - ); - } - } - - private static checkKeys( - owner: Collection, - targetOwner: Collection, - keyName: string, - targetName: string, - ): void { - RelationCollectionDecorator.checkColumn(owner, keyName); - RelationCollectionDecorator.checkColumn(targetOwner, targetName); - - const key = owner.schema.fields[keyName] as ColumnSchema; - const target = targetOwner.schema.fields[targetName] as ColumnSchema; - - if (key.columnType !== target.columnType) { - throw new Error( - `Types from '${owner.name}.${keyName}' and ` + - `'${targetOwner.name}.${targetName}' do not match.`, - ); - } - } - - private static checkColumn(owner: Collection, name: string): void { - const column = owner.schema.fields[name]; - - if (!column || column.type !== 'Column') { - throw new Error(`Column not found: '${owner.name}.${name}'`); - } - - if (!column.filterOperators?.has('In')) { - throw new Error(`Column does not support the In operator: '${owner.name}.${name}'`); - } - } - - private rewriteField(field: string): string[] { - const prefix = field.split(':').shift(); - const schema = this.schema.fields[prefix]; - if (schema.type === 'Column') return [field]; - - const relation = this.dataSource.getCollection(schema.foreignCollection); - let result = [] as string[]; - - if (!this.relations[prefix]) { - result = relation - .rewriteField(field.substring(prefix.length + 1)) - .map(subField => `${prefix}:${subField}`); - } else if (schema.type === 'ManyToOne') { - result = [schema.foreignKey]; - } else if ( - schema.type === 'OneToOne' || - schema.type === 'OneToMany' || - schema.type === 'ManyToMany' - ) { - result = [schema.originKeyTarget]; - } - - return result; - } - - private async rewriteLeaf(caller: Caller, leaf: ConditionTreeLeaf): Promise { - const prefix = leaf.field.split(':').shift(); - const schema = this.schema.fields[prefix]; - if (schema.type === 'Column') return leaf; - - const relation = this.dataSource.getCollection(schema.foreignCollection); - let result = leaf as ConditionTree; - - if (!this.relations[prefix]) { - result = (await relation.rewriteLeaf(caller, leaf.unnest())).nest(prefix); - } else if (schema.type === 'ManyToOne') { - const records = await relation.list( - caller, - new Filter({ conditionTree: leaf.unnest() }), - new Projection(schema.foreignKeyTarget), - ); - - result = new ConditionTreeLeaf(schema.foreignKey, 'In', [ - ...new Set( - records - .map(record => RecordUtils.getFieldValue(record, schema.foreignKeyTarget)) - .filter(v => v !== null), - ), - ]); - } else if (schema.type === 'OneToOne') { - const records = await relation.list( - caller, - new Filter({ conditionTree: leaf.unnest() }), - new Projection(schema.originKey), - ); - - result = new ConditionTreeLeaf(schema.originKeyTarget, 'In', [ - ...new Set( - records - .map(record => RecordUtils.getFieldValue(record, schema.originKey)) - .filter(v => v !== null), - ), - ]); - } - - return result; - } - - private async reprojectInPlace( - caller: Caller, - records: RecordData[], - projection: Projection, - ): Promise { - const promises = Object.entries(projection.relations).map(async ([prefix, subProjection]) => - this.reprojectRelationInPlace(caller, records, prefix, subProjection), - ); - - await Promise.all(promises); - } - - private async reprojectRelationInPlace( - caller: Caller, - records: RecordData[], - name: string, - projection: Projection, - ): Promise { - const schema = this.schema.fields[name] as RelationSchema; - const association = this.dataSource.getCollection(schema.foreignCollection); - - if (!this.relations[name]) { - await association.reprojectInPlace( - caller, - records.map(r => r[name]).filter(Boolean) as RecordData[], - projection, - ); - } else if (schema.type === 'ManyToOne') { - const ids = records.map(record => record[schema.foreignKey]).filter(fk => fk !== null); - const subFilter = new Filter({ - conditionTree: new ConditionTreeLeaf(schema.foreignKeyTarget, 'In', [...new Set(ids)]), - }); - const subRecords = await association.list( - caller, - subFilter, - projection.union([schema.foreignKeyTarget]), - ); - - for (const record of records) { - record[name] = subRecords.find( - sr => sr[schema.foreignKeyTarget] === record[schema.foreignKey], - ); - } - } else if (schema.type === 'OneToOne' || schema.type === 'OneToMany') { - const ids = records.map(record => record[schema.originKeyTarget]).filter(okt => okt !== null); - const subFilter = new Filter({ - conditionTree: new ConditionTreeLeaf(schema.originKey, 'In', [...new Set(ids)]), - }); - const subRecords = await association.list( - caller, - subFilter, - projection.union([schema.originKey]), - ); - - for (const record of records) { - record[name] = subRecords.find( - sr => sr[schema.originKey] === record[schema.originKeyTarget], - ); - } - } - } -} diff --git a/packages/datasource-customizer/src/decorators/relation/types.ts b/packages/datasource-customizer/src/decorators/relation/types.ts deleted file mode 100644 index 2d4eac331d..0000000000 --- a/packages/datasource-customizer/src/decorators/relation/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { - ManyToManySchema, - ManyToOneSchema, - OneToManySchema, - OneToOneSchema, -} from '@forestadmin/datasource-toolkit'; - -type PartialBy> = Omit & Partial>; - -export type RelationDefinition = - | PartialBy - | PartialBy - | PartialBy;