From 58c34dfa11741014771e54054d772645add6bf20 Mon Sep 17 00:00:00 2001 From: Dogan AY Date: Fri, 18 Oct 2024 11:53:41 +0200 Subject: [PATCH] feat(capabilities): register capabilities route for field filter operators --- packages/agent/src/routes/capabilities.ts | 59 ++++++ packages/agent/src/routes/index.ts | 15 ++ .../src/utils/forest-schema/filterable.ts | 66 +----- .../utils/forest-schema/generator-fields.ts | 5 +- .../agent/test/routes/capabilities.test.ts | 200 ++++++++++++++++++ packages/agent/test/routes/index.test.ts | 15 +- .../utils/forest-schema/filterable.test.ts | 99 +++------ 7 files changed, 314 insertions(+), 145 deletions(-) create mode 100644 packages/agent/src/routes/capabilities.ts create mode 100644 packages/agent/test/routes/capabilities.test.ts diff --git a/packages/agent/src/routes/capabilities.ts b/packages/agent/src/routes/capabilities.ts new file mode 100644 index 0000000000..9f534de59a --- /dev/null +++ b/packages/agent/src/routes/capabilities.ts @@ -0,0 +1,59 @@ +import { DataSource } from '@forestadmin/datasource-toolkit'; +import Router from '@koa/router'; +import { Context } from 'koa'; + +import BaseRoute from './base-route'; +import { ForestAdminHttpDriverServices } from '../services'; +import { AgentOptionsWithDefaults, HttpCode, RouteType } from '../types'; + +export default class Capabilities extends BaseRoute { + readonly type = RouteType.PrivateRoute; + protected readonly dataSource: DataSource; + + constructor( + services: ForestAdminHttpDriverServices, + options: AgentOptionsWithDefaults, + dataSource: DataSource, + ) { + super(services, options); + this.dataSource = dataSource; + } + + setupRoutes(router: Router): void { + router.post(`/capabilities`, this.fetchCapabilities.bind(this)); + } + + private async fetchCapabilities(context: Context) { + const collections = this.dataSource.collections.filter(collection => + (context.request.body as { collectionNames: string[] }).collectionNames?.includes( + collection.name, + ), + ); + + context.response.body = { + collections: + collections?.map(collection => ({ + name: collection.name, + fields: Object.entries(collection.schema.fields) + .map(([fieldName, field]) => { + return field.type === 'Column' + ? { + name: fieldName, + type: field.columnType, + operators: [...field.filterOperators].map(this.pascalCaseToSnakeCase), + } + : null; + }) + .filter(Boolean), + })) ?? [], + }; + context.response.status = HttpCode.Ok; + } + + private pascalCaseToSnakeCase(str: string): string { + return str + .split(/\.?(?=[A-Z])/) + .join('_') + .toLowerCase(); + } +} diff --git a/packages/agent/src/routes/index.ts b/packages/agent/src/routes/index.ts index 5f33975c98..fcd283ddca 100644 --- a/packages/agent/src/routes/index.ts +++ b/packages/agent/src/routes/index.ts @@ -11,6 +11,7 @@ import Get from './access/get'; import List from './access/list'; import ListRelated from './access/list-related'; import BaseRoute from './base-route'; +import Capabilities from './capabilities'; import ActionRoute from './modification/action/action'; import AssociateRelated from './modification/associate-related'; import Create from './modification/create'; @@ -55,6 +56,7 @@ export const RELATED_ROUTES_CTOR = [ ListRelated, ]; export const RELATED_RELATION_ROUTES_CTOR = [UpdateRelation]; +export const CAPABILITIES_ROUTES_CTOR = [Capabilities]; function getRootRoutes(options: Options, services: Services): BaseRoute[] { return ROOT_ROUTES_CTOR.map(Route => new Route(services, options)); @@ -92,6 +94,18 @@ function getCrudRoutes(dataSource: DataSource, options: Options, services: Servi return routes; } +function getCapabilitiesRoutes( + dataSource: DataSource, + options: Options, + services: Services, +): BaseRoute[] { + const routes: BaseRoute[] = []; + + routes.push(...CAPABILITIES_ROUTES_CTOR.map(Route => new Route(services, options, dataSource))); + + return routes; +} + function getRelatedRoutes( dataSource: DataSource, options: Options, @@ -144,6 +158,7 @@ export default function makeRoutes( const routes = [ ...getRootRoutes(options, services), ...getCrudRoutes(dataSource, options, services), + ...getCapabilitiesRoutes(dataSource, options, services), ...getApiChartRoutes(dataSource, options, services), ...getRelatedRoutes(dataSource, options, services), ...getActionRoutes(dataSource, options, services), diff --git a/packages/agent/src/utils/forest-schema/filterable.ts b/packages/agent/src/utils/forest-schema/filterable.ts index 603739fc62..7baf1ff92d 100644 --- a/packages/agent/src/utils/forest-schema/filterable.ts +++ b/packages/agent/src/utils/forest-schema/filterable.ts @@ -1,73 +1,13 @@ -import { ColumnType, Operator, PrimitiveTypes } from '@forestadmin/datasource-toolkit'; +import { Operator } from '@forestadmin/datasource-toolkit'; export default class FrontendFilterableUtils { - private static readonly baseOperators: Operator[] = ['Equal', 'NotEqual', 'Present', 'Blank']; - - private static readonly dateOperators: Operator[] = [ - ...FrontendFilterableUtils.baseOperators, - 'LessThan', - 'GreaterThan', - 'Today', - 'Yesterday', - 'PreviousXDays', - 'PreviousWeek', - 'PreviousQuarter', - 'PreviousYear', - 'PreviousXDaysToDate', - 'PreviousWeekToDate', - 'PreviousMonthToDate', - 'PreviousQuarterToDate', - 'PreviousYearToDate', - 'Past', - 'Future', - 'BeforeXHoursAgo', - 'AfterXHoursAgo', - ]; - - private static readonly operatorByType: Partial> = { - Boolean: FrontendFilterableUtils.baseOperators, - Date: FrontendFilterableUtils.dateOperators, - Dateonly: FrontendFilterableUtils.dateOperators, - Enum: [...FrontendFilterableUtils.baseOperators, 'In'], - Number: [...FrontendFilterableUtils.baseOperators, 'In', 'GreaterThan', 'LessThan'], - String: [ - ...FrontendFilterableUtils.baseOperators, - 'In', - 'StartsWith', - 'EndsWith', - 'Contains', - 'NotContains', - ], - Time: [...FrontendFilterableUtils.baseOperators, 'GreaterThan', 'LessThan'], - Uuid: FrontendFilterableUtils.baseOperators, - }; - /** * Compute if a column if filterable according to forestadmin's frontend. * - * @param type column's type (string, number, or a composite type) * @param operators list of operators that the column supports * @returns either if the frontend would consider this column filterable or not. */ - static isFilterable(type: ColumnType, operators?: Set): boolean { - const neededOperators = FrontendFilterableUtils.getRequiredOperators(type); - const supportedOperators = operators ?? new Set(); - - return Boolean(neededOperators && neededOperators.every(op => supportedOperators.has(op))); - } - - static getRequiredOperators(type: ColumnType): Operator[] | null { - if (typeof type === 'string' && FrontendFilterableUtils.operatorByType[type]) { - return FrontendFilterableUtils.operatorByType[type]; - } - - // It sound highly unlikely that this operator can work with dates, or nested objects - // and they should be more restricted, however the frontend code does not seems to check the - // array's content so I'm replicating the same test here - if (Array.isArray(type)) { - return ['IncludesAll']; - } - - return null; + static isFilterable(operators: Set): boolean { + return Boolean(operators && operators.size > 0); } } diff --git a/packages/agent/src/utils/forest-schema/generator-fields.ts b/packages/agent/src/utils/forest-schema/generator-fields.ts index cd73040c7a..f6abbce0f1 100644 --- a/packages/agent/src/utils/forest-schema/generator-fields.ts +++ b/packages/agent/src/utils/forest-schema/generator-fields.ts @@ -68,7 +68,7 @@ export default class SchemaGeneratorFields { field: name, integration: null, inverseOf: null, - isFilterable: FrontendFilterableUtils.isFilterable(column.columnType, column.filterOperators), + isFilterable: FrontendFilterableUtils.isFilterable(column.filterOperators), isPrimaryKey: Boolean(column.isPrimaryKey), // When a column is a foreign key, it is readonly. @@ -158,8 +158,7 @@ export default class SchemaGeneratorFields { private static isForeignCollectionFilterable(foreignCollection: Collection): boolean { return Object.values(foreignCollection.schema.fields).some( field => - field.type === 'Column' && - FrontendFilterableUtils.isFilterable(field.columnType, field.filterOperators), + field.type === 'Column' && FrontendFilterableUtils.isFilterable(field.filterOperators), ); } diff --git a/packages/agent/test/routes/capabilities.test.ts b/packages/agent/test/routes/capabilities.test.ts new file mode 100644 index 0000000000..327991df5e --- /dev/null +++ b/packages/agent/test/routes/capabilities.test.ts @@ -0,0 +1,200 @@ +import { allowedOperatorsForColumnType } from '@forestadmin/datasource-toolkit'; +import { createMockContext } from '@shopify/jest-koa-mocks'; + +import Capabilities from '../../src/routes/capabilities'; +import * as factories from '../__factories__'; + +describe('Capabilities', () => { + const defaultContext = { + state: { user: { email: 'john.doe@domain.com' } }, + customProperties: { query: { timezone: 'Europe/Paris' } }, + }; + const options = factories.forestAdminHttpDriverOptions.build(); + const router = factories.router.mockAllMethods().build(); + const services = factories.forestAdminHttpDriverServices.build(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should register "/capabilities" route', () => { + const dataSource = factories.dataSource.buildWithCollection( + factories.collection.build({ name: 'books' }), + ); + const route = new Capabilities(services, options, dataSource); + route.setupRoutes(router); + + expect(router.post).toHaveBeenCalledWith('/capabilities', expect.any(Function)); + }); + + describe('with the route mounted', () => { + let route: Capabilities; + beforeEach(() => { + const dataSource = factories.dataSource.buildWithCollection( + factories.collection.build({ + name: 'books', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.text().build(), + publishedAt: factories.columnSchema.build({ + columnType: 'Date', + filterOperators: new Set(allowedOperatorsForColumnType.Date), + }), + price: factories.columnSchema.build({ + columnType: 'Number', + filterOperators: new Set(allowedOperatorsForColumnType.Number), + }), + }, + }), + }), + ); + route = new Capabilities(services, options, dataSource); + }); + + describe('when request body does not list any collection name', () => { + test('should return nothing', async () => { + const context = createMockContext({ + ...defaultContext, + requestBody: { collectionNames: [] }, + }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await route.fetchCapabilities(context); + + expect(context.response.body).toEqual({ + collections: [], + }); + }); + + test('should handle empty body', async () => { + const context = createMockContext({ + ...defaultContext, + requestBody: {}, + }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await route.fetchCapabilities(context); + + expect(context.response.body).toEqual({ + collections: [], + }); + }); + }); + + describe('when requesting a collection capabilities', () => { + test('should return the capabilities', async () => { + const context = createMockContext({ + ...defaultContext, + requestBody: { collectionNames: ['books'] }, + }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await route.fetchCapabilities(context); + + expect(context.response.body).toEqual({ + collections: [ + { + name: 'books', + fields: [ + { + name: 'id', + type: 'Uuid', + operators: [ + 'blank', + 'equal', + 'missing', + 'not_equal', + 'present', + 'in', + 'not_in', + 'includes_all', + 'includes_none', + ], + }, + { + name: 'name', + type: 'String', + operators: [ + 'blank', + 'equal', + 'missing', + 'not_equal', + 'present', + 'in', + 'not_in', + 'includes_all', + 'includes_none', + 'contains', + 'not_contains', + 'ends_with', + 'starts_with', + 'longer_than', + 'shorter_than', + 'like', + 'i_like', + 'i_contains', + 'not_i_contains', + 'i_ends_with', + 'i_starts_with', + ], + }, + { + name: 'publishedAt', + type: 'Date', + operators: [ + 'blank', + 'equal', + 'missing', + 'not_equal', + 'present', + 'today', + 'yesterday', + 'previous_x_days_to_date', + 'previous_week', + 'previous_week_to_date', + 'previous_month', + 'previous_month_to_date', + 'previous_quarter', + 'previous_quarter_to_date', + 'previous_year', + 'previous_year_to_date', + 'past', + 'future', + 'previous_x_days', + 'before', + 'after', + 'before_x_hours_ago', + 'after_x_hours_ago', + ], + }, + { + name: 'price', + type: 'Number', + operators: [ + 'blank', + 'equal', + 'missing', + 'not_equal', + 'present', + 'in', + 'not_in', + 'includes_all', + 'includes_none', + 'greater_than', + 'less_than', + 'greater_than_or_equal', + 'less_than_or_equal', + ], + }, + ], + }, + ], + }); + }); + }); + }); +}); diff --git a/packages/agent/test/routes/index.test.ts b/packages/agent/test/routes/index.test.ts index 99ab4d7b8f..4ece79871a 100644 --- a/packages/agent/test/routes/index.test.ts +++ b/packages/agent/test/routes/index.test.ts @@ -1,6 +1,7 @@ import { DataSource } from '@forestadmin/datasource-toolkit'; import makeRoutes, { + CAPABILITIES_ROUTES_CTOR, COLLECTION_ROUTES_CTOR, RELATED_RELATION_ROUTES_CTOR, RELATED_ROUTES_CTOR, @@ -14,6 +15,7 @@ import CsvRelated from '../../src/routes/access/csv-related'; import Get from '../../src/routes/access/get'; import List from '../../src/routes/access/list'; import ListRelated from '../../src/routes/access/list-related'; +import Capabilities from '../../src/routes/capabilities'; import AssociateRelated from '../../src/routes/modification/associate-related'; import Create from '../../src/routes/modification/create'; import Delete from '../../src/routes/modification/delete'; @@ -58,9 +60,12 @@ describe('Route index', () => { DissociateDeleteRelated, ListRelated, ]); + expect(CAPABILITIES_ROUTES_CTOR).toEqual([Capabilities]); expect(RELATED_RELATION_ROUTES_CTOR).toEqual([UpdateRelation]); }); + const BASE_ROUTE_SIZE = ROOT_ROUTES_CTOR.length + CAPABILITIES_ROUTES_CTOR.length; + describe('makeRoutes', () => { describe('when a data source without relations', () => { const setupWithoutRelation = (): DataSource => { @@ -80,7 +85,7 @@ describe('Route index', () => { ); const countCollectionRoutes = COLLECTION_ROUTES_CTOR.length * dataSource.collections.length; - expect(routes.length).toEqual(ROOT_ROUTES_CTOR.length + countCollectionRoutes); + expect(routes.length).toEqual(BASE_ROUTE_SIZE + countCollectionRoutes); }); test('should order routes correctly', async () => { @@ -136,7 +141,7 @@ describe('Route index', () => { const countCollectionRoutes = COLLECTION_ROUTES_CTOR.length * dataSource.collections.length; expect(routes.length).toEqual( - ROOT_ROUTES_CTOR.length + countCollectionRoutes + RELATED_ROUTES_CTOR.length, + BASE_ROUTE_SIZE + countCollectionRoutes + RELATED_ROUTES_CTOR.length, ); }); }); @@ -187,7 +192,7 @@ describe('Route index', () => { const countCollectionRoutes = COLLECTION_ROUTES_CTOR.length * dataSource.collections.length; expect(routes.length).toEqual( - ROOT_ROUTES_CTOR.length + + BASE_ROUTE_SIZE + countCollectionRoutes + RELATED_ROUTES_CTOR.length * 2 + RELATED_RELATION_ROUTES_CTOR.length * 2, @@ -226,7 +231,7 @@ describe('Route index', () => { const countCollectionRoutes = COLLECTION_ROUTES_CTOR.length * dataSource.collections.length; expect(routes.length).toEqual( - ROOT_ROUTES_CTOR.length + countCollectionRoutes + RELATED_RELATION_ROUTES_CTOR.length, + BASE_ROUTE_SIZE + countCollectionRoutes + RELATED_RELATION_ROUTES_CTOR.length, ); }); }); @@ -256,7 +261,7 @@ describe('Route index', () => { ); // because there are two charts, there are two routes in addition to the basic ones - expect(routes.length).toEqual(ROOT_ROUTES_CTOR.length + COLLECTION_ROUTES_CTOR.length + 2); + expect(routes.length).toEqual(BASE_ROUTE_SIZE + COLLECTION_ROUTES_CTOR.length + 2); }); }); }); diff --git a/packages/agent/test/utils/forest-schema/filterable.test.ts b/packages/agent/test/utils/forest-schema/filterable.test.ts index 1b426add91..400cba38ad 100644 --- a/packages/agent/test/utils/forest-schema/filterable.test.ts +++ b/packages/agent/test/utils/forest-schema/filterable.test.ts @@ -3,86 +3,37 @@ import { allOperators } from '@forestadmin/datasource-toolkit/dist/src/interface import FilterableUtils from '../../../src/utils/forest-schema/filterable'; describe('FrontendFilterableUtils', () => { - describe('Normal types need to have defined minimum operators', () => { - test('With undefined operators', () => { - const isFilterable = FilterableUtils.isFilterable('String'); - expect(isFilterable).toBe(false); - }); - - test('With no operators', () => { - const isFilterable = FilterableUtils.isFilterable('String', new Set()); - expect(isFilterable).toBe(false); - }); - - test('With only the relevant operators', () => { - const isFilterable = FilterableUtils.isFilterable( - 'String', - new Set([ - 'Equal', - 'NotEqual', - 'Present', - 'Blank', - 'In', - 'StartsWith', - 'EndsWith', - 'Contains', - 'NotContains', - ]), - ); - - expect(isFilterable).toBe(true); - }); - - test('With all operators', () => { - const isFilterable = FilterableUtils.isFilterable('String', new Set(allOperators)); - - expect(isFilterable).toBeTruthy(); - }); + test('With undefined operators', () => { + const isFilterable = FilterableUtils.isFilterable(undefined); + expect(isFilterable).toBe(false); }); - describe('Arrays need the IncludesAll operator', () => { - test('With no operators', () => { - const isFilterable = FilterableUtils.isFilterable(['String'], new Set()); - expect(isFilterable).toBe(false); - }); - - test('With includeAll', () => { - const isFilterable = FilterableUtils.isFilterable(['String'], new Set(['IncludesAll'])); - - expect(isFilterable).toBeTruthy(); - }); + test('With no operators', () => { + const isFilterable = FilterableUtils.isFilterable(new Set()); + expect(isFilterable).toBe(false); }); - describe('Point is never filterable', () => { - test('With no operators', () => { - const isFilterable = FilterableUtils.isFilterable('Point', new Set()); - expect(isFilterable).toBe(false); - }); - - test('With all operators', () => { - const isFilterable = FilterableUtils.isFilterable('Point', new Set(allOperators)); - - expect(isFilterable).toBe(false); - }); + test('With some operators', () => { + const isFilterable = FilterableUtils.isFilterable( + new Set([ + 'Equal', + 'NotEqual', + 'Present', + 'Blank', + 'In', + 'StartsWith', + 'EndsWith', + 'Contains', + 'NotContains', + ]), + ); + + expect(isFilterable).toBe(true); }); - describe('Nested types are never filterable', () => { - test('With no operators', () => { - const isFilterable = FilterableUtils.isFilterable( - { firstName: 'String', lastName: 'String' }, - new Set(), - ); - - expect(isFilterable).toBe(false); - }); - - test('With all operators', () => { - const isFilterable = FilterableUtils.isFilterable( - { firstName: 'String', lastName: 'String' }, - new Set(allOperators), - ); + test('With all operators', () => { + const isFilterable = FilterableUtils.isFilterable(new Set(allOperators)); - expect(isFilterable).toBe(false); - }); + expect(isFilterable).toBeTruthy(); }); });