Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(native-query): execute chart query requests #1225

Merged
merged 14 commits into from
Dec 18, 2024
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');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not tested because it is not possible due to isQueryChartRequest check

}

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
Loading