diff --git a/.eslintrc.js b/.eslintrc.js index 37c89694ba..c7309bcfdc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,7 +17,7 @@ module.exports = { sourceType: 'module', project: ['./tsconfig.eslint.json'], }, - plugins: ['@typescript-eslint', 'prettier', 'jest', 'jest-formatting'], + plugins: ['@typescript-eslint', 'prettier', 'jest', 'jest-formatting', 'sort-class-members'], rules: { /**********/ /** Style */ @@ -48,7 +48,21 @@ module.exports = { alphabetize: { order: 'asc', caseInsensitive: true }, }, ], - +"sort-class-members/sort-class-members": [ + 2, + { + "order": [ + "[static-properties]", + "[static-methods]", + "[properties]", + "constructor", + "[conventional-private-properties]", + "[methods]", + "[conventional-private-methods]" + ], + "accessorPairPositioning": "getThenSet" + } + ], /***********************************/ /* Stricter rules than airbnb-base */ /***********************************/ diff --git a/packages/_example/package.json b/packages/_example/package.json index e57fbcc0a8..ab7313da8d 100644 --- a/packages/_example/package.json +++ b/packages/_example/package.json @@ -45,6 +45,7 @@ "db:seed": "ts-node scripts/db-seed.ts" }, "devDependencies": { + "eslint-plugin-sort-class-members": "^1.21.0", "ts-node": "^10.9.2" } } diff --git a/packages/agent/src/framework-mounter.ts b/packages/agent/src/framework-mounter.ts index 519a9b5525..34f0374ab4 100644 --- a/packages/agent/src/framework-mounter.ts +++ b/packages/agent/src/framework-mounter.ts @@ -18,16 +18,16 @@ export default class FrameworkMounter { private readonly prefix: string; private readonly logger: Logger; - /** Compute the prefix that the main router should be mounted at in the client's application */ - private get completeMountPrefix(): string { - return path.posix.join('/', this.prefix, 'forest'); - } - constructor(prefix: string, logger: Logger) { this.prefix = prefix; this.logger = logger; } + /** Compute the prefix that the main router should be mounted at in the client's application */ + private get completeMountPrefix(): string { + return path.posix.join('/', this.prefix, 'forest'); + } + protected async mount(router: Router): Promise { for (const task of this.onFirstStart) await task(); // eslint-disable-line no-await-in-loop diff --git a/packages/agent/src/routes/access/api-chart-collection.ts b/packages/agent/src/routes/access/api-chart-collection.ts index 238fea7a0e..78088b6ed1 100644 --- a/packages/agent/src/routes/access/api-chart-collection.ts +++ b/packages/agent/src/routes/access/api-chart-collection.ts @@ -13,10 +13,6 @@ import CollectionRoute from '../collection-route'; export default class CollectionApiChartRoute extends CollectionRoute { private chartName: string; - get chartUrlSlug(): string { - return this.escapeUrlSlug(this.chartName); - } - constructor( services: ForestAdminHttpDriverServices, options: AgentOptionsWithDefaults, @@ -28,6 +24,10 @@ export default class CollectionApiChartRoute extends CollectionRoute { this.chartName = chartName; } + get chartUrlSlug(): string { + return this.escapeUrlSlug(this.chartName); + } + setupRoutes(router: Router): void { // Mount both GET and POST, respectively for smart and api charts. const suffix = `/_charts/${this.collectionUrlSlug}/${this.chartUrlSlug}`; diff --git a/packages/agent/src/routes/access/api-chart-datasource.ts b/packages/agent/src/routes/access/api-chart-datasource.ts index 1c9d5f7bb1..3da3e54b2e 100644 --- a/packages/agent/src/routes/access/api-chart-datasource.ts +++ b/packages/agent/src/routes/access/api-chart-datasource.ts @@ -15,10 +15,6 @@ export default class DataSourceApiChartRoute extends BaseRoute { private dataSource: DataSource; private chartName: string; - get chartUrlSlug(): string { - return this.escapeUrlSlug(this.chartName); - } - constructor( services: ForestAdminHttpDriverServices, options: AgentOptionsWithDefaults, @@ -31,6 +27,10 @@ export default class DataSourceApiChartRoute extends BaseRoute { this.chartName = chartName; } + get chartUrlSlug(): string { + return this.escapeUrlSlug(this.chartName); + } + setupRoutes(router: Router): void { // Mount both GET and POST, respectively for smart and api charts. const suffix = `/_charts/${this.chartUrlSlug}`; diff --git a/packages/agent/src/routes/base-route.ts b/packages/agent/src/routes/base-route.ts index c6b03495cc..81b51759b0 100644 --- a/packages/agent/src/routes/base-route.ts +++ b/packages/agent/src/routes/base-route.ts @@ -7,12 +7,11 @@ export default abstract class BaseRoute { protected readonly services: ForestAdminHttpDriverServices; protected readonly options: AgentOptionsWithDefaults; - abstract get type(): RouteType; - constructor(services: ForestAdminHttpDriverServices, options: AgentOptionsWithDefaults) { this.services = services; this.options = options; } + abstract get type(): RouteType; async bootstrap(): Promise { // Do nothing by default diff --git a/packages/agent/src/routes/collection-route.ts b/packages/agent/src/routes/collection-route.ts index 40b78dd9cc..ce91e620b9 100644 --- a/packages/agent/src/routes/collection-route.ts +++ b/packages/agent/src/routes/collection-route.ts @@ -10,14 +10,6 @@ export default abstract class CollectionRoute extends BaseRoute { private readonly collectionName: string; protected readonly dataSource: DataSource; - protected get collection(): Collection { - return this.dataSource.getCollection(this.collectionName); - } - - protected get collectionUrlSlug(): string { - return this.escapeUrlSlug(this.collectionName); - } - constructor( services: ForestAdminHttpDriverServices, options: AgentOptionsWithDefaults, @@ -28,4 +20,12 @@ export default abstract class CollectionRoute extends BaseRoute { this.collectionName = collectionName; this.dataSource = dataSource; } + + protected get collection(): Collection { + return this.dataSource.getCollection(this.collectionName); + } + + protected get collectionUrlSlug(): string { + return this.escapeUrlSlug(this.collectionName); + } } diff --git a/packages/agent/src/routes/modification/action/action-authorization.ts b/packages/agent/src/routes/modification/action/action-authorization.ts index 68066eab31..375cb2dc7d 100644 --- a/packages/agent/src/routes/modification/action/action-authorization.ts +++ b/packages/agent/src/routes/modification/action/action-authorization.ts @@ -24,6 +24,100 @@ type CanPerformCustomActionParams = { }; export default class ActionAuthorizationService { + private static async canPerformConditionalCustomAction( + caller: Caller, + collection: Collection, + requestFilter: Filter, + condition?: unknown, + ) { + if (condition) { + const [requestRecordsCount, matchingRecordsCount] = await Promise.all([ + ActionAuthorizationService.aggregateCountConditionIntersection( + caller, + collection, + requestFilter, + ), + ActionAuthorizationService.aggregateCountConditionIntersection( + caller, + collection, + requestFilter, + condition, + ), + ]); + + // If all records condition the condition everything is ok + // Otherwise when some records don't match the condition then the user + // is not allow to perform the conditional action + return matchingRecordsCount === requestRecordsCount; + } + + return true; + } + + private static async aggregateCountConditionIntersection( + caller: Caller, + collection: Collection, + requestFilter: Filter, + condition?: unknown, + ) { + try { + // Override request filter with condition if any + const conditionalFilter = requestFilter.override({ + conditionTree: condition + ? ConditionTreeFactory.intersect( + ConditionTreeParser.fromPlainObject(collection, condition), + requestFilter.conditionTree, + ) + : requestFilter.conditionTree, + }); + + const rows = await collection.aggregate( + caller, + conditionalFilter, + new Aggregation({ + operation: 'Count', + }), + ); + + return (rows?.[0]?.value as number) ?? 0; + } catch (error) { + throw new InvalidActionConditionError(); + } + } + + /** + * Given a map it groups keys based on their hash values + */ + private static transformToRolesIdsGroupByConditions( + actionConditionsByRoleId: Map, + ): { + roleIds: number[]; + condition: T; + }[] { + const rolesIdsGroupByConditions = Array.from( + actionConditionsByRoleId, + ([roleId, condition]) => { + return { + roleId, + condition, + conditionHash: hashObject(condition as Record, { respectType: false }), + }; + }, + ).reduce((acc, current) => { + const { roleId, condition, conditionHash } = current; + + if (acc.has(conditionHash)) { + acc.get(conditionHash).roleIds.push(roleId); + } else { + acc.set(conditionHash, { roleIds: [roleId], condition }); + } + + return acc; + }, new Map()); + + return Array.from(rolesIdsGroupByConditions.values()); + } + constructor(private readonly forestAdminClient: ForestAdminClient) {} public async assertCanTriggerCustomAction({ @@ -274,98 +368,4 @@ export default class ActionAuthorizationService { roleIdsAllowedToApproveWithoutConditions, ); } - - private static async canPerformConditionalCustomAction( - caller: Caller, - collection: Collection, - requestFilter: Filter, - condition?: unknown, - ) { - if (condition) { - const [requestRecordsCount, matchingRecordsCount] = await Promise.all([ - ActionAuthorizationService.aggregateCountConditionIntersection( - caller, - collection, - requestFilter, - ), - ActionAuthorizationService.aggregateCountConditionIntersection( - caller, - collection, - requestFilter, - condition, - ), - ]); - - // If all records condition the condition everything is ok - // Otherwise when some records don't match the condition then the user - // is not allow to perform the conditional action - return matchingRecordsCount === requestRecordsCount; - } - - return true; - } - - private static async aggregateCountConditionIntersection( - caller: Caller, - collection: Collection, - requestFilter: Filter, - condition?: unknown, - ) { - try { - // Override request filter with condition if any - const conditionalFilter = requestFilter.override({ - conditionTree: condition - ? ConditionTreeFactory.intersect( - ConditionTreeParser.fromPlainObject(collection, condition), - requestFilter.conditionTree, - ) - : requestFilter.conditionTree, - }); - - const rows = await collection.aggregate( - caller, - conditionalFilter, - new Aggregation({ - operation: 'Count', - }), - ); - - return (rows?.[0]?.value as number) ?? 0; - } catch (error) { - throw new InvalidActionConditionError(); - } - } - - /** - * Given a map it groups keys based on their hash values - */ - private static transformToRolesIdsGroupByConditions( - actionConditionsByRoleId: Map, - ): { - roleIds: number[]; - condition: T; - }[] { - const rolesIdsGroupByConditions = Array.from( - actionConditionsByRoleId, - ([roleId, condition]) => { - return { - roleId, - condition, - conditionHash: hashObject(condition as Record, { respectType: false }), - }; - }, - ).reduce((acc, current) => { - const { roleId, condition, conditionHash } = current; - - if (acc.has(conditionHash)) { - acc.get(conditionHash).roleIds.push(roleId); - } else { - acc.set(conditionHash, { roleIds: [roleId], condition }); - } - - return acc; - }, new Map()); - - return Array.from(rolesIdsGroupByConditions.values()); - } } diff --git a/packages/agent/src/routes/relation-route.ts b/packages/agent/src/routes/relation-route.ts index f34a20319e..d367096c81 100644 --- a/packages/agent/src/routes/relation-route.ts +++ b/packages/agent/src/routes/relation-route.ts @@ -7,6 +7,17 @@ import { AgentOptionsWithDefaults } from '../types'; export default abstract class RelationRoute extends CollectionRoute { protected readonly relationName: string; + constructor( + services: ForestAdminHttpDriverServices, + options: AgentOptionsWithDefaults, + dataSource: DataSource, + collectionName: string, + relationName: string, + ) { + super(services, options, dataSource, collectionName); + this.relationName = relationName; + } + protected get foreignCollection(): Collection { const schema = SchemaUtils.getRelation( this.collection.schema, @@ -20,15 +31,4 @@ export default abstract class RelationRoute extends CollectionRoute { protected get relationUrlSlug(): string { return this.escapeUrlSlug(this.relationName); } - - constructor( - services: ForestAdminHttpDriverServices, - options: AgentOptionsWithDefaults, - dataSource: DataSource, - collectionName: string, - relationName: string, - ) { - super(services, options, dataSource, collectionName); - this.relationName = relationName; - } } diff --git a/packages/agent/src/routes/security/authentication.ts b/packages/agent/src/routes/security/authentication.ts index aea63878db..bab3f9c8f3 100644 --- a/packages/agent/src/routes/security/authentication.ts +++ b/packages/agent/src/routes/security/authentication.ts @@ -9,6 +9,12 @@ import { RouteType } from '../../types'; import BaseRoute from '../base-route'; export default class Authentication extends BaseRoute { + private static checkRenderingId(renderingId: number): void { + if (Number.isNaN(renderingId)) { + throw new ValidationError('Rendering id must be a number'); + } + } + readonly type = RouteType.Authentication; override async bootstrap(): Promise { @@ -71,12 +77,6 @@ export default class Authentication extends BaseRoute { context.response.body = { token, tokenData: jsonwebtoken.decode(token) }; } - private static checkRenderingId(renderingId: number): void { - if (Number.isNaN(renderingId)) { - throw new ValidationError('Rendering id must be a number'); - } - } - private async handleError(context: Context, next: Next): Promise { try { await next(); diff --git a/packages/agent/src/services/model-customizations/customization.ts b/packages/agent/src/services/model-customizations/customization.ts index d676eea38b..5e4fccc83f 100644 --- a/packages/agent/src/services/model-customizations/customization.ts +++ b/packages/agent/src/services/model-customizations/customization.ts @@ -18,29 +18,6 @@ const featuresFormattedWithVersion = [ ]; export default class CustomizationPluginService { - private readonly client: ForestAdminClient; - - private readonly options: AgentOptionsWithDefaults; - - public constructor(agentOptions: AgentOptionsWithDefaults) { - this.client = agentOptions.forestAdminClient; - - this.options = agentOptions; - } - - public addCustomizations: Plugin = async (datasourceCustomizer, _) => { - const { experimental } = this.options; - if (!experimental) return; - - const modelCustomizations = await this.client.modelCustomizationService.getConfiguration(); - - CustomizationPluginService.makeAddCustomizations(experimental)( - datasourceCustomizer, - _, - modelCustomizations, - ); - }; - public static makeAddCustomizations: ( experimental: ExperimentalOptions, ) => Plugin = (experimental: ExperimentalOptions) => { @@ -59,10 +36,6 @@ export default class CustomizationPluginService { }; }; - public buildFeatures() { - return CustomizationPluginService.buildFeatures(this.options?.experimental); - } - public static buildFeatures(experimental: ExperimentalOptions): Record | null { const features = CustomizationPluginService.getFeatures(experimental); @@ -86,4 +59,31 @@ export default class CustomizationPluginService { .filter(([experimentalFeature]) => experimental?.[experimentalFeature]) .map(([, feature]) => feature); } + + private readonly client: ForestAdminClient; + + private readonly options: AgentOptionsWithDefaults; + + public addCustomizations: Plugin = async (datasourceCustomizer, _) => { + const { experimental } = this.options; + if (!experimental) return; + + const modelCustomizations = await this.client.modelCustomizationService.getConfiguration(); + + CustomizationPluginService.makeAddCustomizations(experimental)( + datasourceCustomizer, + _, + modelCustomizations, + ); + }; + + public constructor(agentOptions: AgentOptionsWithDefaults) { + this.client = agentOptions.forestAdminClient; + + this.options = agentOptions; + } + + public buildFeatures() { + return CustomizationPluginService.buildFeatures(this.options?.experimental); + } } diff --git a/packages/datasource-mongoose/src/collection.ts b/packages/datasource-mongoose/src/collection.ts index 059088d6d2..54cf2d2118 100644 --- a/packages/datasource-mongoose/src/collection.ts +++ b/packages/datasource-mongoose/src/collection.ts @@ -59,20 +59,6 @@ export default class MongooseCollection extends BaseCollection { return this._list(filter, projection); } - private async _list(filter: PaginatedFilter, projection: Projection): Promise { - const lookupProjection = projection.union( - filter.conditionTree?.projection, - filter.sort?.projection, - ); - - const pipeline = [ - ...this.buildBasePipeline(filter, lookupProjection), - ...ProjectionGenerator.project(projection), - ]; - - return addNullValues(replaceMongoTypes(await this.model.aggregate(pipeline)), projection); - } - async aggregate( caller: Caller, filter: Filter, @@ -102,30 +88,6 @@ export default class MongooseCollection extends BaseCollection { return this.handleValidationError(() => this._delete(caller, filter)); } - private async _create(caller: Caller, flatData: RecordData[]): Promise { - const { asFields } = this.stack[this.stack.length - 1]; - const data = flatData.map(fd => unflattenRecord(fd, asFields)); - - // For root models, we can simply insert the records. - if (this.stack.length < 2) { - const { insertedIds } = await this.model.insertMany(data, { rawResult: true }); - - return flatData.map((flatRecord, index) => ({ - _id: replaceMongoTypes(insertedIds[index]), - ...flatRecord, - })); - } - - // Only array fields can create subdocuments (the others should use update) - const schema = MongooseSchema.fromModel(this.model).applyStack(this.stack); - - if (schema.isArray) { - return this.createForArraySubfield(data, flatData, schema); - } - - return this.createForObjectSubfield(data, flatData); - } - private computeSubFieldName() { const lastStackStep = this.stack[this.stack.length - 1]; @@ -134,6 +96,20 @@ export default class MongooseCollection extends BaseCollection { : lastStackStep.prefix; } + private async _list(filter: PaginatedFilter, projection: Projection): Promise { + const lookupProjection = projection.union( + filter.conditionTree?.projection, + filter.sort?.projection, + ); + + const pipeline = [ + ...this.buildBasePipeline(filter, lookupProjection), + ...ProjectionGenerator.project(projection), + ]; + + return addNullValues(replaceMongoTypes(await this.model.aggregate(pipeline)), projection); + } + private async createForArraySubfield( data: RecordData[], flatData: RecordData[], @@ -192,6 +168,93 @@ export default class MongooseCollection extends BaseCollection { return [{ _id: `${rootId}.${path}`, ...flatData[0] }]; } + private buildBasePipeline( + filter: PaginatedFilter, + lookupProjection: Projection, + ): PipelineStage[] { + const fieldsUsedInFilters = FilterGenerator.listRelationsUsedInFilter(filter); + + const [preSortAndPaginate, sortAndPaginatePostFiltering, sortAndPaginateAll] = + FilterGenerator.sortAndPaginate(this.model, filter); + + const reparentStages = ReparentGenerator.reparent(this.model, this.stack); + const addVirtualStages = VirtualFieldsGenerator.addVirtual( + this.model, + this.stack, + lookupProjection, + ); + // For performance reasons, we want to only include the relationships that are used in filters + // before applying the filters + const lookupUsedInFiltersStage = LookupGenerator.lookup( + this.model, + this.stack, + lookupProjection, + { + include: fieldsUsedInFilters, + }, + ); + const filterStage = FilterGenerator.filter(this.model, this.stack, filter); + + // Here are the remaining relationships that are not used in filters. For performance reasons + // they are computed after the filters. + const lookupNotFilteredStage = LookupGenerator.lookup( + this.model, + this.stack, + lookupProjection, + { + exclude: fieldsUsedInFilters, + }, + ); + + return [ + ...preSortAndPaginate, + ...reparentStages, + ...addVirtualStages, + ...lookupUsedInFiltersStage, + ...filterStage, + ...sortAndPaginatePostFiltering, + ...lookupNotFilteredStage, + ...sortAndPaginateAll, + ]; + } + + private async handleValidationError(callback: () => Promise): Promise { + try { + // Do not remove the await here, it's important! + return await callback(); + } catch (error) { + if (error instanceof Error.ValidationError) { + throw new ValidationError(error.message); + } + + throw error; + } + } + + private async _create(caller: Caller, flatData: RecordData[]): Promise { + const { asFields } = this.stack[this.stack.length - 1]; + const data = flatData.map(fd => unflattenRecord(fd, asFields)); + + // For root models, we can simply insert the records. + if (this.stack.length < 2) { + const { insertedIds } = await this.model.insertMany(data, { rawResult: true }); + + return flatData.map((flatRecord, index) => ({ + _id: replaceMongoTypes(insertedIds[index]), + ...flatRecord, + })); + } + + // Only array fields can create subdocuments (the others should use update) + const schema = MongooseSchema.fromModel(this.model).applyStack(this.stack); + + if (schema.isArray) { + return this.createForArraySubfield(data, flatData, schema); + } + + return this.createForObjectSubfield(data, flatData); + } + private async _update(caller: Caller, filter: Filter, flatPatch: RecordData): Promise { const { asFields } = this.stack[this.stack.length - 1]; const patch = unflattenRecord(flatPatch, asFields, true); @@ -295,67 +358,4 @@ export default class MongooseCollection extends BaseCollection { await Promise.all(promises); } } - - private buildBasePipeline( - filter: PaginatedFilter, - lookupProjection: Projection, - ): PipelineStage[] { - const fieldsUsedInFilters = FilterGenerator.listRelationsUsedInFilter(filter); - - const [preSortAndPaginate, sortAndPaginatePostFiltering, sortAndPaginateAll] = - FilterGenerator.sortAndPaginate(this.model, filter); - - const reparentStages = ReparentGenerator.reparent(this.model, this.stack); - const addVirtualStages = VirtualFieldsGenerator.addVirtual( - this.model, - this.stack, - lookupProjection, - ); - // For performance reasons, we want to only include the relationships that are used in filters - // before applying the filters - const lookupUsedInFiltersStage = LookupGenerator.lookup( - this.model, - this.stack, - lookupProjection, - { - include: fieldsUsedInFilters, - }, - ); - const filterStage = FilterGenerator.filter(this.model, this.stack, filter); - - // Here are the remaining relationships that are not used in filters. For performance reasons - // they are computed after the filters. - const lookupNotFilteredStage = LookupGenerator.lookup( - this.model, - this.stack, - lookupProjection, - { - exclude: fieldsUsedInFilters, - }, - ); - - return [ - ...preSortAndPaginate, - ...reparentStages, - ...addVirtualStages, - ...lookupUsedInFiltersStage, - ...filterStage, - ...sortAndPaginatePostFiltering, - ...lookupNotFilteredStage, - ...sortAndPaginateAll, - ]; - } - - private async handleValidationError(callback: () => Promise): Promise { - try { - // Do not remove the await here, it's important! - return await callback(); - } catch (error) { - if (error instanceof Error.ValidationError) { - throw new ValidationError(error.message); - } - - throw error; - } - } } diff --git a/packages/datasource-mongoose/src/mongoose/schema.ts b/packages/datasource-mongoose/src/mongoose/schema.ts index 998fe0743e..1d17e60119 100644 --- a/packages/datasource-mongoose/src/mongoose/schema.ts +++ b/packages/datasource-mongoose/src/mongoose/schema.ts @@ -9,28 +9,51 @@ export type SchemaBranch = { [key: string]: SchemaNode }; export type SchemaNode = SchemaType | SchemaBranch; export default class MongooseSchema { - readonly isArray: boolean; - readonly isLeaf: boolean; - readonly fields: SchemaBranch; - - private models: { [name: string]: Model }; - static fromModel(model: Model): MongooseSchema { return new MongooseSchema(model.db.models, this.buildFields(model.schema), false, false); } - get schemaNode(): SchemaNode { - return this.isLeaf ? this.fields.content : this.fields; - } + /** + * Build a tree of SchemaType from a mongoose schema. + * This removes most complexity from using prefixes, nested schemas and array types + */ + private static buildFields(schema: Schema, level = 0): SchemaBranch { + const paths = {}; - get schemaType(): SchemaType { - if (this.isLeaf) { - return this.fields.content as SchemaType; + for (const [name, field] of Object.entries(schema.paths)) { + // Exclude mixedFieldPattern $* and privateFieldPattern __ + if (!name.startsWith('$*') && !name.includes('__') && (name !== '_id' || level === 0)) { + // Flatten nested schemas and arrays + if (field.constructor.name === 'SubdocumentPath') { + const subPaths = this.buildFields(field.schema as Schema, level + 1); + + for (const [subName, subField] of Object.entries(subPaths)) { + recursiveSet(paths, `${name}.${subName}`, subField); + } + } else if (field.constructor.name === 'DocumentArrayPath') { + const subPaths = this.buildFields(field.schema as Schema, level + 1); + + for (const [subName, subField] of Object.entries(subPaths)) { + recursiveSet(paths, `${name}.[].${subName}`, subField); + } + } else if (field.constructor.name === 'SchemaArray') { + recursiveSet(paths, `${name}.[]`, (field as any).caster); + } else { + recursiveSet(paths, name, field); + } + } } - throw new Error(`Schema is not a leaf.`); + return paths; } + readonly isArray: boolean; + + readonly isLeaf: boolean; + readonly fields: SchemaBranch; + + private models: { [name: string]: Model }; + constructor( models: Record>, fields: SchemaBranch, @@ -43,6 +66,18 @@ export default class MongooseSchema { this.isLeaf = isLeaf; } + get schemaNode(): SchemaNode { + return this.isLeaf ? this.fields.content : this.fields; + } + + get schemaType(): SchemaType { + if (this.isLeaf) { + return this.fields.content as SchemaType; + } + + throw new Error(`Schema is not a leaf.`); + } + listPathsMatching( handle: (field: string, schema: MongooseSchema) => boolean, prefix?: string, @@ -161,38 +196,4 @@ export default class MongooseSchema { return new MongooseSchema(this.models, child, isArray, isLeaf).getSubSchema(suffix); } - - /** - * Build a tree of SchemaType from a mongoose schema. - * This removes most complexity from using prefixes, nested schemas and array types - */ - private static buildFields(schema: Schema, level = 0): SchemaBranch { - const paths = {}; - - for (const [name, field] of Object.entries(schema.paths)) { - // Exclude mixedFieldPattern $* and privateFieldPattern __ - if (!name.startsWith('$*') && !name.includes('__') && (name !== '_id' || level === 0)) { - // Flatten nested schemas and arrays - if (field.constructor.name === 'SubdocumentPath') { - const subPaths = this.buildFields(field.schema as Schema, level + 1); - - for (const [subName, subField] of Object.entries(subPaths)) { - recursiveSet(paths, `${name}.${subName}`, subField); - } - } else if (field.constructor.name === 'DocumentArrayPath') { - const subPaths = this.buildFields(field.schema as Schema, level + 1); - - for (const [subName, subField] of Object.entries(subPaths)) { - recursiveSet(paths, `${name}.[].${subName}`, subField); - } - } else if (field.constructor.name === 'SchemaArray') { - recursiveSet(paths, `${name}.[]`, (field as any).caster); - } else { - recursiveSet(paths, name, field); - } - } - } - - return paths; - } } diff --git a/packages/datasource-replica/src/synchronization/analysis-passthrough.ts b/packages/datasource-replica/src/synchronization/analysis-passthrough.ts index 7e89a075e4..ab67b23ff4 100644 --- a/packages/datasource-replica/src/synchronization/analysis-passthrough.ts +++ b/packages/datasource-replica/src/synchronization/analysis-passthrough.ts @@ -21,6 +21,13 @@ export default class AnalysisPassThough implements SynchronizationTarget, Synchr source: SynchronizationSource; namespace: string; + constructor(connection: Sequelize, source: SynchronizationSource, namespace: string) { + this.source = source; + this.recordCache = connection.model(`${namespace}_pending_operations`); + this.namespace = namespace; + this.nodes = {}; + } + get requestCache(): CacheDataSourceInterface { return this.source?.requestCache; } @@ -29,13 +36,6 @@ export default class AnalysisPassThough implements SynchronizationTarget, Synchr this.source.requestCache = value; } - constructor(connection: Sequelize, source: SynchronizationSource, namespace: string) { - this.source = source; - this.recordCache = connection.model(`${namespace}_pending_operations`); - this.namespace = namespace; - this.nodes = {}; - } - async start(target: SynchronizationTarget): Promise { if (!this.target) { // Replay dump/delta that we received while analyzing the schema diff --git a/packages/datasource-sequelize/src/utils/aggregation.ts b/packages/datasource-sequelize/src/utils/aggregation.ts index faf14776c2..dc31cde692 100644 --- a/packages/datasource-sequelize/src/utils/aggregation.ts +++ b/packages/datasource-sequelize/src/utils/aggregation.ts @@ -22,6 +22,15 @@ export default class AggregationUtils { private dateAggregationConverter: DateAggregationConverter; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(model: ModelDefined) { + this.model = model; + this.dialect = this.model.sequelize.getDialect() as Dialect; + this.col = this.model.sequelize.col; + + this.dateAggregationConverter = new DateAggregationConverter(this.model.sequelize); + } + readonly _aggregateFieldName = '__aggregate__'; get aggregateFieldName() { if ( @@ -36,15 +45,6 @@ export default class AggregationUtils { return this._aggregateFieldName; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(model: ModelDefined) { - this.model = model; - this.dialect = this.model.sequelize.getDialect() as Dialect; - this.col = this.model.sequelize.col; - - this.dateAggregationConverter = new DateAggregationConverter(this.model.sequelize); - } - private getGroupFieldName(groupField: string) { return `${groupField}__grouped__`; } diff --git a/packages/datasource-toolkit/src/decorators/collection-decorator.ts b/packages/datasource-toolkit/src/decorators/collection-decorator.ts index 319e87c0f8..bc97d9c83f 100644 --- a/packages/datasource-toolkit/src/decorators/collection-decorator.ts +++ b/packages/datasource-toolkit/src/decorators/collection-decorator.ts @@ -15,24 +15,6 @@ export default class CollectionDecorator implements Collection { private lastSchema: CollectionSchema; - get nativeDriver(): unknown { - return this.childCollection.nativeDriver; - } - - get schema(): CollectionSchema { - if (!this.lastSchema) { - // If the schema is not cached (at the first call, or after a markSchemaAsDirty call), - const subSchema = this.childCollection.schema; - this.lastSchema = this.refineSchema(subSchema); - } - - return this.lastSchema; - } - - get name(): string { - return this.childCollection.name; - } - constructor(childCollection: Collection, dataSource: DataSource) { this.childCollection = childCollection; this.dataSource = dataSource; @@ -53,6 +35,24 @@ export default class CollectionDecorator implements Collection { } } + get nativeDriver(): unknown { + return this.childCollection.nativeDriver; + } + + get schema(): CollectionSchema { + if (!this.lastSchema) { + // If the schema is not cached (at the first call, or after a markSchemaAsDirty call), + const subSchema = this.childCollection.schema; + this.lastSchema = this.refineSchema(subSchema); + } + + return this.lastSchema; + } + + get name(): string { + return this.childCollection.name; + } + async execute( caller: Caller, name: string, diff --git a/packages/datasource-toolkit/src/decorators/datasource-decorator.ts b/packages/datasource-toolkit/src/decorators/datasource-decorator.ts index c9254345da..39f5279d54 100644 --- a/packages/datasource-toolkit/src/decorators/datasource-decorator.ts +++ b/packages/datasource-toolkit/src/decorators/datasource-decorator.ts @@ -14,6 +14,14 @@ export default class DataSourceDecorator; private readonly decorators: WeakMap = new WeakMap(); + constructor( + childDataSource: DataSource, + CollectionDecoratorCtor: CollectionDecoratorConstructor, + ) { + this.childDataSource = childDataSource; + this.CollectionDecoratorCtor = CollectionDecoratorCtor; + } + get schema(): DataSourceSchema { return this.childDataSource.schema; } @@ -26,14 +34,6 @@ export default class DataSourceDecorator this.getCollection(name)); } - constructor( - childDataSource: DataSource, - CollectionDecoratorCtor: CollectionDecoratorConstructor, - ) { - this.childDataSource = childDataSource; - this.CollectionDecoratorCtor = CollectionDecoratorCtor; - } - getCollection(name: string): CollectionDecorator { const collection = this.childDataSource.getCollection(name); diff --git a/packages/datasource-toolkit/src/errors.ts b/packages/datasource-toolkit/src/errors.ts index 1668e41ebd..3baf9d57a6 100644 --- a/packages/datasource-toolkit/src/errors.ts +++ b/packages/datasource-toolkit/src/errors.ts @@ -1,17 +1,5 @@ // eslint-disable-next-line max-classes-per-file export class BusinessError extends Error { - // INTERNAL USAGES - public readonly isBusinessError = true; - public baseBusinessErrorName: string; - - public readonly data: Record | undefined; - - constructor(message?: string, data?: Record, name?: string) { - super(message); - this.name = name ?? this.constructor.name; - this.data = data; - } - /** * We cannot rely on `instanceof` because there can be some mismatch between * packages versions as dependencies of different packages. @@ -23,6 +11,18 @@ export class BusinessError extends Error { (error as BusinessError).baseBusinessErrorName === ErrorConstructor.name ); } + + // INTERNAL USAGES + public readonly isBusinessError = true; + public baseBusinessErrorName: string; + + public readonly data: Record | undefined; + + constructor(message?: string, data?: Record, name?: string) { + super(message); + this.name = name ?? this.constructor.name; + this.data = data; + } } export class ValidationError extends BusinessError { diff --git a/packages/datasource-toolkit/src/interfaces/query/aggregation.ts b/packages/datasource-toolkit/src/interfaces/query/aggregation.ts index 57dd1cb29e..3a39530dba 100644 --- a/packages/datasource-toolkit/src/interfaces/query/aggregation.ts +++ b/packages/datasource-toolkit/src/interfaces/query/aggregation.ts @@ -36,6 +36,12 @@ export default class Aggregation { operation: GenericAggregation['operation']; groups?: GenericAggregation['groups']; + constructor(components: GenericAggregation) { + this.field = components.field; + this.operation = components.operation; + this.groups = components.groups; + } + get projection(): Projection { const { field, groups } = this; const aggregateFields = [field, ...(groups ?? []).map(b => b.field)].filter(Boolean); @@ -43,12 +49,6 @@ export default class Aggregation { return new Projection(...aggregateFields); } - constructor(components: GenericAggregation) { - this.field = components.field; - this.operation = components.operation; - this.groups = components.groups; - } - apply(records: RecordData[], timezone: string, limit?: number): AggregateResult[] { const rows = this.formatSummaries(this.createSummaries(records, timezone)); diff --git a/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/branch.ts b/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/branch.ts index e6ae287277..fb9d7b110b 100644 --- a/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/branch.ts +++ b/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/branch.ts @@ -21,6 +21,12 @@ export default class ConditionTreeBranch extends ConditionTree { aggregator: Aggregator; conditions: ConditionTree[]; + constructor(aggregator: Aggregator, conditions: ConditionTree[]) { + super(); + this.aggregator = aggregator; + this.conditions = conditions; + } + get projection(): Projection { return this.conditions.reduce( (memo, condition) => memo.union(condition.projection), @@ -28,12 +34,6 @@ export default class ConditionTreeBranch extends ConditionTree { ); } - constructor(aggregator: Aggregator, conditions: ConditionTree[]) { - super(); - this.aggregator = aggregator; - this.conditions = conditions; - } - forEachLeaf(handler: LeafCallback): void { this.conditions.forEach(c => c.forEachLeaf(handler)); } diff --git a/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/leaf.ts b/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/leaf.ts index c231d8e607..e3bd38c06d 100644 --- a/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/leaf.ts +++ b/packages/datasource-toolkit/src/interfaces/query/condition-tree/nodes/leaf.ts @@ -22,6 +22,13 @@ export default class ConditionTreeLeaf extends ConditionTree { operator: Operator; value?: unknown; + constructor(field: string, operator: Operator, value?: unknown) { + super(); + this.field = field; + this.operator = operator; + this.value = value; + } + get projection(): Projection { return new Projection(this.field); } @@ -30,13 +37,6 @@ export default class ConditionTreeLeaf extends ConditionTree { return intervalOperators.includes(this.operator as (typeof intervalOperators)[number]); } - constructor(field: string, operator: Operator, value?: unknown) { - super(); - this.field = field; - this.operator = operator; - this.value = value; - } - forEachLeaf(handler: LeafCallback): void { handler(this); } diff --git a/packages/datasource-toolkit/src/interfaces/query/filter/unpaginated.ts b/packages/datasource-toolkit/src/interfaces/query/filter/unpaginated.ts index 8143659265..112997269e 100644 --- a/packages/datasource-toolkit/src/interfaces/query/filter/unpaginated.ts +++ b/packages/datasource-toolkit/src/interfaces/query/filter/unpaginated.ts @@ -28,10 +28,6 @@ export default class Filter { segment?: string; liveQuerySegment?: LiveQuerySegment; - get isNestable(): boolean { - return !this.search && !this.segment; - } - constructor(parts: FilterComponents) { this.conditionTree = parts.conditionTree; this.search = parts.search; @@ -40,6 +36,10 @@ export default class Filter { this.liveQuerySegment = parts.liveQuerySegment; } + get isNestable(): boolean { + return !this.search && !this.segment; + } + override(fields: FilterComponents): Filter { return new Filter({ ...this, ...fields }); } diff --git a/packages/forest-cloud/src/services/bootstrap-path-manager.ts b/packages/forest-cloud/src/services/bootstrap-path-manager.ts index 338e8fd762..30e8f1fa55 100644 --- a/packages/forest-cloud/src/services/bootstrap-path-manager.ts +++ b/packages/forest-cloud/src/services/bootstrap-path-manager.ts @@ -1,11 +1,7 @@ import path from 'path'; export default class BootstrapPathManager { - private readonly _tmp: string; - private readonly _home: string; - private _folderName: string; private readonly basePath: string; - constructor(tmp: string, home: string, basePath?: string) { this._tmp = tmp; this._home = home; @@ -13,6 +9,11 @@ export default class BootstrapPathManager { this.basePath = basePath ?? '.'; } + private readonly _tmp: string; + + private readonly _home: string; + private _folderName: string; + get folderName(): string { return this._folderName || 'forest-cloud'; } diff --git a/packages/forest-cloud/src/services/http-server.ts b/packages/forest-cloud/src/services/http-server.ts index 16f4dfd473..5d58c848d9 100644 --- a/packages/forest-cloud/src/services/http-server.ts +++ b/packages/forest-cloud/src/services/http-server.ts @@ -45,17 +45,6 @@ async function handledAxios( } export default class HttpServer { - private readonly serverUrl: string; - private readonly headers: object; - constructor(serverUrl: string, secretKey: string, bearerToken: string) { - this.serverUrl = serverUrl; - this.headers = { - 'forest-secret-key': secretKey, - Authorization: `Bearer ${bearerToken}`, - 'Content-Type': 'application/json', - }; - } - public static async downloadCloudCustomizerTemplate(destination: string) { const response = await axios.default({ url: 'https://github.com/ForestAdmin/cloud-customizer/archive/main.zip', @@ -72,6 +61,22 @@ export default class HttpServer { }); } + static async getLatestVersion(packageName: string): Promise { + return latestVersion(packageName); + } + + private readonly serverUrl: string; + + private readonly headers: object; + constructor(serverUrl: string, secretKey: string, bearerToken: string) { + this.serverUrl = serverUrl; + this.headers = { + 'forest-secret-key': secretKey, + Authorization: `Bearer ${bearerToken}`, + 'Content-Type': 'application/json', + }; + } + async getDatasources(): Promise { return handledAxios( { @@ -151,10 +156,6 @@ export default class HttpServer { ).logs; } - static async getLatestVersion(packageName: string): Promise { - return latestVersion(packageName); - } - async getOrCreateNewDevelopmentEnvironment() { try { return await handledAxios<{ data: { attributes: { secret_key: string } } }>( diff --git a/packages/forestadmin-client/src/schema/index.ts b/packages/forestadmin-client/src/schema/index.ts index add4464e0f..a4df3c2d47 100644 --- a/packages/forestadmin-client/src/schema/index.ts +++ b/packages/forestadmin-client/src/schema/index.ts @@ -8,25 +8,6 @@ import ServerUtils from '../utils/server'; type SerializedSchema = { meta: { schemaFileHash: string } }; export default class SchemaService { - constructor(private options: ForestAdminClientOptionsWithDefaults) {} - - async postSchema(schema: ForestSchema): Promise { - const apimap = SchemaService.serialize(schema); - const shouldSend = await this.doServerWantsSchema(apimap.meta.schemaFileHash); - - if (shouldSend) { - await ServerUtils.query(this.options, 'post', '/forest/apimaps', {}, apimap); - } - - const message = shouldSend - ? 'Schema was updated, sending new version' - : 'Schema was not updated since last run'; - - this.options.logger('Info', `${message} (hash: ${apimap.meta.schemaFileHash})`); - - return shouldSend; - } - static serialize(schema: ForestSchema): SerializedSchema { const data = schema.collections.map(c => ({ id: c.name, ...c })); const schemaFileHash = crypto.createHash('sha1').update(JSON.stringify(schema)).digest('hex'); @@ -53,6 +34,25 @@ export default class SchemaService { return serializer; } + constructor(private options: ForestAdminClientOptionsWithDefaults) {} + + async postSchema(schema: ForestSchema): Promise { + const apimap = SchemaService.serialize(schema); + const shouldSend = await this.doServerWantsSchema(apimap.meta.schemaFileHash); + + if (shouldSend) { + await ServerUtils.query(this.options, 'post', '/forest/apimaps', {}, apimap); + } + + const message = shouldSend + ? 'Schema was updated, sending new version' + : 'Schema was not updated since last run'; + + this.options.logger('Info', `${message} (hash: ${apimap.meta.schemaFileHash})`); + + return shouldSend; + } + private async doServerWantsSchema(hash: string): Promise { // Check if the schema was already sent by another agent const { sendSchema } = await ServerUtils.query<{ sendSchema: boolean }>( diff --git a/yarn.lock b/yarn.lock index 8807594745..a7d011932c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7062,6 +7062,11 @@ eslint-plugin-prettier@^4.2.1: dependencies: prettier-linter-helpers "^1.0.0" +eslint-plugin-sort-class-members@^1.21.0: + version "1.21.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-sort-class-members/-/eslint-plugin-sort-class-members-1.21.0.tgz#e0ee1e5eddf698d5c997a071133b0cc51bb3de34" + integrity sha512-QKV4jvGMu/ge1l4s1TUBC6rqqV/fbABWY7q2EeNpV3FRikoX6KuLhiNvS8UuMi+EERe0hKGrNU9e6ukFDxNnZQ== + eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" @@ -14395,7 +14400,16 @@ string-similarity@^4.0.1: resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b" integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -14462,7 +14476,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -14490,6 +14504,13 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -15643,7 +15664,7 @@ wordwrap@>=0.0.2, wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -15661,6 +15682,15 @@ wrap-ansi@^6.0.1: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"