Skip to content

Commit

Permalink
feat(native-query): execute chart query requests (#1225)
Browse files Browse the repository at this point in the history
  • Loading branch information
DayTF authored Dec 18, 2024
1 parent 31be3ed commit 84b26dd
Show file tree
Hide file tree
Showing 34 changed files with 1,171 additions and 73 deletions.
10 changes: 8 additions & 2 deletions packages/_example/src/forest/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ export default function makeAgent() {

.addDataSource(
// Using an URI
createSqlDataSource('mariadb://example:password@localhost:3808/example'),
createSqlDataSource('mariadb://example:password@localhost:3808/example', {
liveQueryConnections: 'Main database',
}),
{ include: ['customer'] },
)
.addDataSource(
Expand All @@ -52,7 +54,11 @@ export default function makeAgent() {
{ include: ['card', 'active_cards'] }, // active_cards is a view
)
.addDataSource(createTypicode())
.addDataSource(createSequelizeDataSource(sequelizePostgres))
.addDataSource(
createSequelizeDataSource(sequelizePostgres, {
liveQueryConnections: 'Business intel',
}),
)
.addDataSource(createSequelizeDataSource(sequelizeMySql))
.addDataSource(createSequelizeDataSource(sequelizeMsSql))
.addDataSource(
Expand Down
161 changes: 161 additions & 0 deletions packages/agent/src/routes/access/native-query-datasource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Caller, DataSource, UnprocessableError } from '@forestadmin/datasource-toolkit';
import { ChartType, QueryChart } from '@forestadmin/forestadmin-client';
import Router from '@koa/router';
import { Context } from 'koa';
import { v1 as uuidv1 } from 'uuid';

import { ForestAdminHttpDriverServices } from '../../services';
import { AgentOptionsWithDefaults, RouteType } from '../../types';
import BaseRoute from '../base-route';

function isQueryChartRequest(body): body is QueryChart {
return (
Boolean(body.query) &&
Object.values(ChartType).includes(body.type) &&
body.type !== ChartType.Smart
);
}

export default class DataSourceNativeQueryRoute extends BaseRoute {
readonly type = RouteType.PrivateRoute;
private dataSource: DataSource;

constructor(
services: ForestAdminHttpDriverServices,
options: AgentOptionsWithDefaults,
dataSource: DataSource,
) {
super(services, options);
this.dataSource = dataSource;
}

setupRoutes(router: Router): void {
router.post(`/_internal/native_query`, this.handleNativeQuery.bind(this));
}

private async handleNativeQuery(context: Context) {
if (!isQueryChartRequest(context.request.body)) {
throw new UnprocessableError('Native query endpoint only supports Query Chart Requests');
}

const chartRequest = context.request.body;

if (!chartRequest.connectionName) {
throw new UnprocessableError('Missing native query connection attribute');
}

if (!this.dataSource.nativeQueryConnections[chartRequest.connectionName]) {
throw new UnprocessableError(
`Native query connection '${chartRequest.connectionName}' is unknown`,
);
}

await this.services.authorization.assertCanExecuteChart(context);

context.response.body = {
data: {
id: uuidv1(),
type: 'stats',
attributes: { value: await this.makeChart(context, chartRequest) },
},
};
}

private async makeChart(context: Context, chartRequest: QueryChart) {
const { renderingId, id: userId } = <Caller>context.state.user;

const { query, contextVariables } = await this.services.chartHandler.getQueryForChart({
userId,
renderingId,
chartRequest,
});

let result;

try {
result = (await this.dataSource.executeNativeQuery(
chartRequest.connectionName,
query,
contextVariables,
)) as Record<string, unknown>[];
} catch (error) {
throw new UnprocessableError(
`Error during chart native query execution: ${(error as Error).message}`,
);
}

let body;

switch (chartRequest.type) {
case ChartType.Value:
if (result.length) {
const resultLine = result[0];

if (resultLine.value === undefined) {
this.getErrorQueryColumnsName(resultLine, ['value']);
} else {
body = {
countCurrent: resultLine.value,
countPrevious: resultLine.previous,
};
}
}

break;
case ChartType.Pie:
case ChartType.Leaderboard:
if (result.length) {
result.forEach(resultLine => {
if (resultLine.value === undefined || resultLine.key === undefined) {
this.getErrorQueryColumnsName(resultLine, ['key', 'value']);
}
});
}

break;
case ChartType.Line:
if (result.length) {
result.forEach(resultLine => {
if (resultLine.value === undefined || resultLine.key === undefined) {
this.getErrorQueryColumnsName(resultLine, ['key', 'value']);
}
});
}

body = result.map(resultLine => ({
label: resultLine.key,
values: {
value: resultLine.value,
},
}));
break;
case ChartType.Objective:
if (result.length) {
const resultLine = result[0];

if (resultLine.value === undefined || resultLine.objective === undefined) {
this.getErrorQueryColumnsName(resultLine, ['value', 'objective']);
} else {
body = {
objective: resultLine.objective,
value: resultLine.value,
};
}
}

break;
default:
throw new Error('Unknown Chart type');
}

return body || result;
}

private getErrorQueryColumnsName(result: Record<string, unknown>, keyNames: string[]) {
const message = `The result columns must be named ${keyNames.join(
', ',
)} instead of '${Object.keys(result).join("', '")}'.`;

throw new UnprocessableError(message);
}
}
15 changes: 15 additions & 0 deletions packages/agent/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import CsvRelated from './access/csv-related';
import Get from './access/get';
import List from './access/list';
import ListRelated from './access/list-related';
import NativeQueryDatasource from './access/native-query-datasource';
import BaseRoute from './base-route';
import Capabilities from './capabilities';
import ActionRoute from './modification/action/action';
Expand Down Expand Up @@ -57,6 +58,7 @@ export const RELATED_ROUTES_CTOR = [
];
export const RELATED_RELATION_ROUTES_CTOR = [UpdateRelation];
export const CAPABILITIES_ROUTES_CTOR = [Capabilities];
export const NATIVE_QUERY_ROUTES_CTOR = [NativeQueryDatasource];

function getRootRoutes(options: Options, services: Services): BaseRoute[] {
return ROOT_ROUTES_CTOR.map(Route => new Route(services, options));
Expand Down Expand Up @@ -106,6 +108,18 @@ function getCapabilitiesRoutes(
return routes;
}

function getNativeQueryRoutes(
dataSource: DataSource,
options: Options,
services: Services,
): BaseRoute[] {
const routes: BaseRoute[] = [];

routes.push(...NATIVE_QUERY_ROUTES_CTOR.map(Route => new Route(services, options, dataSource)));

return routes;
}

function getRelatedRoutes(
dataSource: DataSource,
options: Options,
Expand Down Expand Up @@ -159,6 +173,7 @@ export default function makeRoutes(
...getRootRoutes(options, services),
...getCrudRoutes(dataSource, options, services),
...getCapabilitiesRoutes(dataSource, options, services),
...getNativeQueryRoutes(dataSource, options, services),
...getApiChartRoutes(dataSource, options, services),
...getRelatedRoutes(dataSource, options, services),
...getActionRoutes(dataSource, options, services),
Expand Down
24 changes: 23 additions & 1 deletion packages/agent/src/services/authorization/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,36 @@ export default class AuthorizationService {
context: Context,
collectionName: string,
) {
const { id: userId } = context.state.user;
const { id: userId, renderingId } = context.state.user;

const canOnCollection = await this.forestAdminClient.permissionService.canOnCollection({
userId,
event,
collectionName,
});

if (
context.request?.body &&
CollectionActionEvent.Browse === event &&
(context.request.body as { segmentQuery?: string }).segmentQuery
) {
const { segmentQuery, connectionName } = context.request.body as {
segmentQuery?: string;
connectionName?: string;
};

const canExecuteSegmentQuery =
await this.forestAdminClient.permissionService.canExecuteSegmentQuery({
userId,
collectionName,
renderingId,
segmentQuery,
connectionName,
});

if (!canExecuteSegmentQuery) context.throw(HttpCode.Forbidden, 'Forbidden');
}

if (!canOnCollection) {
context.throw(HttpCode.Forbidden, 'Forbidden');
}
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/utils/context-filter-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default class ContextFilterFactory {
return new Filter({
search: QueryStringParser.parseSearch(collection, context),
segment: QueryStringParser.parseSegment(collection, context),
liveQuerySegment: QueryStringParser.parseLiveQuerySegment(context),
searchExtended: QueryStringParser.parseSearchExtended(context),
conditionTree: ConditionTreeFactory.intersect(
QueryStringParser.parseConditionTree(collection, context),
Expand Down
17 changes: 17 additions & 0 deletions packages/agent/src/utils/query-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Sort,
SortFactory,
SortValidator,
UnprocessableError,
ValidationError,
} from '@forestadmin/datasource-toolkit';
import { Context } from 'koa';
Expand Down Expand Up @@ -117,6 +118,22 @@ export default class QueryStringParser {
return segment;
}

static parseLiveQuerySegment(context: Context) {
const { query } = context.request as any;
const segmentQuery = query.segmentQuery?.toString();
const connectionName = query.connectionName?.toString();

if (!segmentQuery) {
return null;
}

if (!connectionName) {
throw new UnprocessableError('Missing native query connection attribute');
}

return { query: segmentQuery, connectionName };
}

static parseCaller(context: Context): Caller {
const timezone = context.request.query.timezone?.toString();
const { ip } = context.request;
Expand Down
Loading

0 comments on commit 84b26dd

Please sign in to comment.