mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-07-01 10:42:19 +08:00
fix(data-vi): fix issues when using external oracle data source for data visualization (#5436)
* fix(data-vi): fix issues when using external oracle data source for data visualization * fix: bug * fix: bug * fix: oracle group by * fix: oracle formatter
This commit is contained in:
parent
b361c9528c
commit
b36586f324
@ -10,7 +10,8 @@
|
|||||||
import { Database, Repository } from '@nocobase/database';
|
import { Database, Repository } from '@nocobase/database';
|
||||||
import { MockServer, createMockServer } from '@nocobase/test';
|
import { MockServer, createMockServer } from '@nocobase/test';
|
||||||
import compose from 'koa-compose';
|
import compose from 'koa-compose';
|
||||||
import { parseBuilder, parseFieldAndAssociations, queryData } from '../actions/query';
|
import { parseFieldAndAssociations, queryData } from '../actions/query';
|
||||||
|
import { createQueryParser } from '../query-parser';
|
||||||
|
|
||||||
describe('api', () => {
|
describe('api', () => {
|
||||||
let app: MockServer;
|
let app: MockServer;
|
||||||
@ -91,7 +92,8 @@ describe('api', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
await compose([parseFieldAndAssociations, parseBuilder, queryData])(ctx, async () => {});
|
const queryParser = createQueryParser(db);
|
||||||
|
await compose([parseFieldAndAssociations, queryParser.parse(), queryData])(ctx, async () => {});
|
||||||
expect(ctx.action.params.values.data).toBeDefined();
|
expect(ctx.action.params.values.data).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -125,7 +127,8 @@ describe('api', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
await compose([parseFieldAndAssociations, parseBuilder, queryData])(ctx, async () => {});
|
const queryParser = createQueryParser(db);
|
||||||
|
await compose([parseFieldAndAssociations, queryParser.parse(), queryData])(ctx, async () => {});
|
||||||
expect(ctx.action.params.values.data).toBeDefined();
|
expect(ctx.action.params.values.data).toBeDefined();
|
||||||
expect(ctx.action.params.values.data).toMatchObject([{ createdAt: '2023-01' }, { createdAt: '2023-02' }]);
|
expect(ctx.action.params.values.data).toMatchObject([{ createdAt: '2023-01' }, { createdAt: '2023-02' }]);
|
||||||
});
|
});
|
||||||
|
@ -10,7 +10,8 @@
|
|||||||
import { Database } from '@nocobase/database';
|
import { Database } from '@nocobase/database';
|
||||||
import { MockServer, createMockServer } from '@nocobase/test';
|
import { MockServer, createMockServer } from '@nocobase/test';
|
||||||
import compose from 'koa-compose';
|
import compose from 'koa-compose';
|
||||||
import { parseBuilder, parseFieldAndAssociations, queryData } from '../actions/query';
|
import { parseFieldAndAssociations, queryData } from '../actions/query';
|
||||||
|
import { createQueryParser } from '../query-parser';
|
||||||
|
|
||||||
describe('formatter', () => {
|
describe('formatter', () => {
|
||||||
let app: MockServer;
|
let app: MockServer;
|
||||||
@ -85,7 +86,8 @@ describe('formatter', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
await compose([parseFieldAndAssociations, parseBuilder, queryData])(ctx, async () => {});
|
const queryParser = createQueryParser(db);
|
||||||
|
await compose([parseFieldAndAssociations, queryParser.parse(), queryData])(ctx, async () => {});
|
||||||
expect(ctx.action.params.values.data).toBeDefined();
|
expect(ctx.action.params.values.data).toBeDefined();
|
||||||
expect(ctx.action.params.values.data).toMatchObject([{ date: '2024-05-15 01:02:30' }]);
|
expect(ctx.action.params.values.data).toMatchObject([{ date: '2024-05-15 01:02:30' }]);
|
||||||
});
|
});
|
||||||
@ -125,7 +127,8 @@ describe('formatter', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
await compose([parseFieldAndAssociations, parseBuilder, queryData])(ctx, async () => {});
|
const queryParser = createQueryParser(db);
|
||||||
|
await compose([parseFieldAndAssociations, queryParser.parse(), queryData])(ctx, async () => {});
|
||||||
expect(ctx.action.params.values.data).toBeDefined();
|
expect(ctx.action.params.values.data).toBeDefined();
|
||||||
expect(ctx.action.params.values.data).toMatchObject([{ dateOnly: '2024-05-14' }]);
|
expect(ctx.action.params.values.data).toMatchObject([{ dateOnly: '2024-05-14' }]);
|
||||||
});
|
});
|
||||||
@ -165,7 +168,8 @@ describe('formatter', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
await compose([parseFieldAndAssociations, parseBuilder, queryData])(ctx, async () => {});
|
const queryParser = createQueryParser(db);
|
||||||
|
await compose([parseFieldAndAssociations, queryParser.parse(), queryData])(ctx, async () => {});
|
||||||
expect(ctx.action.params.values.data).toBeDefined();
|
expect(ctx.action.params.values.data).toBeDefined();
|
||||||
expect(ctx.action.params.values.data).toMatchObject([{ datetimeNoTz: '2024-05-14 19:32:30' }]);
|
expect(ctx.action.params.values.data).toMatchObject([{ datetimeNoTz: '2024-05-14 19:32:30' }]);
|
||||||
});
|
});
|
||||||
@ -213,7 +217,8 @@ describe('formatter', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
await compose([parseFieldAndAssociations, parseBuilder, queryData])(ctx, async () => {});
|
const queryParser = createQueryParser(db);
|
||||||
|
await compose([parseFieldAndAssociations, queryParser.parse(), queryData])(ctx, async () => {});
|
||||||
expect(ctx.action.params.values.data).toBeDefined();
|
expect(ctx.action.params.values.data).toBeDefined();
|
||||||
expect(ctx.action.params.values.data).toMatchObject([{ unixTs: '2023-01-01 10:04:56' }]);
|
expect(ctx.action.params.values.data).toMatchObject([{ unixTs: '2023-01-01 10:04:56' }]);
|
||||||
});
|
});
|
||||||
@ -260,7 +265,8 @@ describe('formatter', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
await compose([parseFieldAndAssociations, parseBuilder, queryData])(ctx, async () => {});
|
const queryParser = createQueryParser(db);
|
||||||
|
await compose([parseFieldAndAssociations, queryParser.parse(), queryData])(ctx, async () => {});
|
||||||
expect(ctx.action.params.values.data).toBeDefined();
|
expect(ctx.action.params.values.data).toBeDefined();
|
||||||
expect(ctx.action.params.values.data).toMatchObject([{ unixTsMs: '2023-01-01 10:04:56' }]);
|
expect(ctx.action.params.values.data).toMatchObject([{ unixTsMs: '2023-01-01 10:04:56' }]);
|
||||||
});
|
});
|
||||||
|
@ -13,13 +13,14 @@ import { vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
cacheMiddleware,
|
cacheMiddleware,
|
||||||
checkPermission,
|
checkPermission,
|
||||||
parseBuilder,
|
|
||||||
parseFieldAndAssociations,
|
parseFieldAndAssociations,
|
||||||
parseVariables,
|
parseVariables,
|
||||||
postProcess,
|
postProcess,
|
||||||
} from '../actions/query';
|
} from '../actions/query';
|
||||||
import { Database } from '@nocobase/database';
|
import { Database } from '@nocobase/database';
|
||||||
import * as formatter from '../formatter';
|
import * as formatter from '../formatter';
|
||||||
|
import { createQueryParser } from '../query-parser';
|
||||||
|
import { QueryParser } from '../query-parser/query-parser';
|
||||||
|
|
||||||
describe('query', () => {
|
describe('query', () => {
|
||||||
describe('parseBuilder', () => {
|
describe('parseBuilder', () => {
|
||||||
@ -157,7 +158,8 @@ describe('query', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await compose([parseFieldAndAssociations, parseBuilder])(context, async () => {});
|
const queryParser = createQueryParser(db);
|
||||||
|
await compose([parseFieldAndAssociations, queryParser.parse()])(context, async () => {});
|
||||||
expect(context.action.params.values.queryParams.attributes).toEqual([
|
expect(context.action.params.values.queryParams.attributes).toEqual([
|
||||||
[db.sequelize.col('orders.price'), 'price'],
|
[db.sequelize.col('orders.price'), 'price'],
|
||||||
]);
|
]);
|
||||||
@ -179,7 +181,7 @@ describe('query', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await compose([parseFieldAndAssociations, parseBuilder])(context2, async () => {});
|
await compose([parseFieldAndAssociations, queryParser.parse()])(context2, async () => {});
|
||||||
expect(context2.action.params.values.queryParams.attributes).toEqual([
|
expect(context2.action.params.values.queryParams.attributes).toEqual([
|
||||||
[db.sequelize.fn('sum', db.sequelize.col('orders.price')), 'price-alias'],
|
[db.sequelize.fn('sum', db.sequelize.col('orders.price')), 'price-alias'],
|
||||||
]);
|
]);
|
||||||
@ -203,20 +205,17 @@ describe('query', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const queryParser = createQueryParser(db);
|
||||||
try {
|
try {
|
||||||
await compose([parseFieldAndAssociations, parseBuilder])(context, async () => {});
|
await compose([parseFieldAndAssociations, queryParser.parse()])(context, async () => {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error.message).toBe('Invalid aggregation function: if(1=2,sleep(1),sleep(3)) and sum');
|
expect(error.message).toBe('Invalid aggregation function: if(1=2,sleep(1),sleep(3)) and sum');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should parse dimensions', async () => {
|
it('should parse dimensions', async () => {
|
||||||
vi.spyOn(formatter, 'createFormatter').mockImplementation(
|
const queryParser = createQueryParser(db);
|
||||||
() =>
|
vi.spyOn(queryParser.formatter, 'format').mockImplementation(() => 'formatted-field' as any);
|
||||||
({
|
|
||||||
format: () => 'formatted-field' as any,
|
|
||||||
}) as any,
|
|
||||||
);
|
|
||||||
const dimensions = [
|
const dimensions = [
|
||||||
{
|
{
|
||||||
field: ['createdAt'],
|
field: ['createdAt'],
|
||||||
@ -235,7 +234,7 @@ describe('query', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await compose([parseFieldAndAssociations, parseBuilder])(context, async () => {});
|
await compose([parseFieldAndAssociations, queryParser.parse()])(context, async () => {});
|
||||||
expect(context.action.params.values.queryParams.attributes).toEqual([['formatted-field', 'Created at']]);
|
expect(context.action.params.values.queryParams.attributes).toEqual([['formatted-field', 'Created at']]);
|
||||||
expect(context.action.params.values.queryParams.group).toEqual([]);
|
expect(context.action.params.values.queryParams.group).toEqual([]);
|
||||||
const measures = [
|
const measures = [
|
||||||
@ -256,7 +255,7 @@ describe('query', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await compose([parseFieldAndAssociations, parseBuilder])(context2, async () => {});
|
await compose([parseFieldAndAssociations, queryParser.parse()])(context2, async () => {});
|
||||||
expect(context2.action.params.values.queryParams.group).toEqual(['formatted-field']);
|
expect(context2.action.params.values.queryParams.group).toEqual(['formatted-field']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -277,7 +276,8 @@ describe('query', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await compose([parseFieldAndAssociations, parseBuilder])(context, async () => {});
|
const queryParser = createQueryParser(db);
|
||||||
|
await compose([parseFieldAndAssociations, queryParser.parse()])(context, async () => {});
|
||||||
expect(context.action.params.values.queryParams.where.createdAt).toBeDefined();
|
expect(context.action.params.values.queryParams.where.createdAt).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -332,7 +332,6 @@ describe('query', () => {
|
|||||||
await parseVariables(context, async () => {});
|
await parseVariables(context, async () => {});
|
||||||
const { filter } = context.action.params.values;
|
const { filter } = context.action.params.values;
|
||||||
const dateOn = filter.$and[0].createdAt.$dateOn;
|
const dateOn = filter.$and[0].createdAt.$dateOn;
|
||||||
console.log(dateOn);
|
|
||||||
expect(new Date(dateOn).getTime()).toBeLessThanOrEqual(new Date().getTime());
|
expect(new Date(dateOn).getTime()).toBeLessThanOrEqual(new Date().getTime());
|
||||||
const userId = filter.$and[1].userId.$eq;
|
const userId = filter.$and[1].userId.$eq;
|
||||||
expect(userId).toBe(1);
|
expect(userId).toBe(1);
|
||||||
|
@ -12,52 +12,8 @@ import { BelongsToArrayAssociation, Field, FilterParser } from '@nocobase/databa
|
|||||||
import compose from 'koa-compose';
|
import compose from 'koa-compose';
|
||||||
import { Cache } from '@nocobase/cache';
|
import { Cache } from '@nocobase/cache';
|
||||||
import { middlewares } from '@nocobase/server';
|
import { middlewares } from '@nocobase/server';
|
||||||
import { createFormatter } from '../formatter';
|
import { QueryParams } from '../types';
|
||||||
|
import { createQueryParser } from '../query-parser';
|
||||||
type MeasureProps = {
|
|
||||||
field: string | string[];
|
|
||||||
type?: string;
|
|
||||||
aggregation?: string;
|
|
||||||
alias?: string;
|
|
||||||
distinct?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DimensionProps = {
|
|
||||||
field: string | string[];
|
|
||||||
type?: string;
|
|
||||||
alias?: string;
|
|
||||||
format?: string;
|
|
||||||
options?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OrderProps = {
|
|
||||||
field: string | string[];
|
|
||||||
alias?: string;
|
|
||||||
order?: 'asc' | 'desc';
|
|
||||||
};
|
|
||||||
|
|
||||||
type QueryParams = Partial<{
|
|
||||||
uid: string;
|
|
||||||
dataSource: string;
|
|
||||||
collection: string;
|
|
||||||
measures: MeasureProps[];
|
|
||||||
dimensions: DimensionProps[];
|
|
||||||
orders: OrderProps[];
|
|
||||||
filter: any;
|
|
||||||
limit: number;
|
|
||||||
sql: {
|
|
||||||
fields?: string;
|
|
||||||
clauses?: string;
|
|
||||||
};
|
|
||||||
cache: {
|
|
||||||
enabled: boolean;
|
|
||||||
ttl: number;
|
|
||||||
};
|
|
||||||
// Get the latest data from the database
|
|
||||||
refresh: boolean;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const AllowedAggFuncs = ['sum', 'count', 'avg', 'min', 'max'];
|
|
||||||
|
|
||||||
const getDB = (ctx: Context, dataSource: string) => {
|
const getDB = (ctx: Context, dataSource: string) => {
|
||||||
const ds = ctx.app.dataSourceManager.dataSources.get(dataSource);
|
const ds = ctx.app.dataSourceManager.dataSources.get(dataSource);
|
||||||
@ -109,79 +65,6 @@ export const queryData = async (ctx: Context, next: Next) => {
|
|||||||
// return data;
|
// return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parseBuilder = async (ctx: Context, next: Next) => {
|
|
||||||
const { dataSource, measures, dimensions, orders, include, where, limit } = ctx.action.params.values;
|
|
||||||
const db = getDB(ctx, dataSource) || ctx.db;
|
|
||||||
const { sequelize } = db;
|
|
||||||
const attributes = [];
|
|
||||||
const group = [];
|
|
||||||
const order = [];
|
|
||||||
const fieldMap = {};
|
|
||||||
let hasAgg = false;
|
|
||||||
|
|
||||||
measures.forEach((measure: MeasureProps & { field: string }) => {
|
|
||||||
const { field, aggregation, alias, distinct } = measure;
|
|
||||||
const attribute = [];
|
|
||||||
const col = sequelize.col(field);
|
|
||||||
if (aggregation) {
|
|
||||||
if (!AllowedAggFuncs.includes(aggregation)) {
|
|
||||||
throw new Error(`Invalid aggregation function: ${aggregation}`);
|
|
||||||
}
|
|
||||||
hasAgg = true;
|
|
||||||
attribute.push(sequelize.fn(aggregation, distinct ? sequelize.fn('DISTINCT', col) : col));
|
|
||||||
} else {
|
|
||||||
attribute.push(col);
|
|
||||||
}
|
|
||||||
if (alias) {
|
|
||||||
attribute.push(alias);
|
|
||||||
}
|
|
||||||
attributes.push(attribute.length > 1 ? attribute : attribute[0]);
|
|
||||||
fieldMap[alias || field] = measure;
|
|
||||||
});
|
|
||||||
|
|
||||||
dimensions.forEach((dimension: DimensionProps & { field: string }) => {
|
|
||||||
const { field, format, alias, type, options } = dimension;
|
|
||||||
const attribute = [];
|
|
||||||
const col = sequelize.col(field);
|
|
||||||
if (format) {
|
|
||||||
const formatter = createFormatter(sequelize);
|
|
||||||
attribute.push(formatter.format({ type, field, format, timezone: ctx.timezone, options }));
|
|
||||||
} else {
|
|
||||||
attribute.push(col);
|
|
||||||
}
|
|
||||||
if (alias) {
|
|
||||||
attribute.push(alias);
|
|
||||||
}
|
|
||||||
attributes.push(attribute.length > 1 ? attribute : attribute[0]);
|
|
||||||
if (hasAgg) {
|
|
||||||
group.push(attribute[0]);
|
|
||||||
}
|
|
||||||
fieldMap[alias || field] = dimension;
|
|
||||||
});
|
|
||||||
|
|
||||||
orders.forEach((item: OrderProps) => {
|
|
||||||
const alias = sequelize.getQueryInterface().quoteIdentifier(item.alias);
|
|
||||||
const name = hasAgg ? sequelize.literal(alias) : sequelize.col(item.field as string);
|
|
||||||
order.push([name, item.order || 'ASC']);
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.action.params.values = {
|
|
||||||
...ctx.action.params.values,
|
|
||||||
queryParams: {
|
|
||||||
where,
|
|
||||||
attributes,
|
|
||||||
include,
|
|
||||||
group,
|
|
||||||
order,
|
|
||||||
limit: limit || 2000,
|
|
||||||
subQuery: false,
|
|
||||||
raw: true,
|
|
||||||
},
|
|
||||||
fieldMap,
|
|
||||||
};
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const parseFieldAndAssociations = async (ctx: Context, next: Next) => {
|
export const parseFieldAndAssociations = async (ctx: Context, next: Next) => {
|
||||||
const {
|
const {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -323,13 +206,16 @@ export const checkPermission = (ctx: Context, next: Next) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const query = async (ctx: Context, next: Next) => {
|
export const query = async (ctx: Context, next: Next) => {
|
||||||
|
const { dataSource } = ctx.action.params.values as QueryParams;
|
||||||
|
const db = getDB(ctx, dataSource) || ctx.db;
|
||||||
|
const queryParser = createQueryParser(db);
|
||||||
try {
|
try {
|
||||||
await compose([
|
await compose([
|
||||||
checkPermission,
|
checkPermission,
|
||||||
cacheMiddleware,
|
cacheMiddleware,
|
||||||
parseVariables,
|
parseVariables,
|
||||||
parseFieldAndAssociations,
|
parseFieldAndAssociations,
|
||||||
parseBuilder,
|
queryParser.parse(),
|
||||||
queryData,
|
queryData,
|
||||||
postProcess,
|
postProcess,
|
||||||
])(ctx, next);
|
])(ctx, next);
|
||||||
|
@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* This file is part of the NocoBase (R) project.
|
|
||||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
||||||
* Authors: NocoBase Team.
|
|
||||||
*
|
|
||||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Sequelize } from 'sequelize';
|
|
||||||
import { SQLiteFormatter } from './sqlite-formatter';
|
|
||||||
import { PostgresFormatter } from './postgres-formatter';
|
|
||||||
import { MySQLFormatter } from './mysql-formatter';
|
|
||||||
|
|
||||||
export const createFormatter = (sequelize: Sequelize) => {
|
|
||||||
const dialect = sequelize.getDialect();
|
|
||||||
switch (dialect) {
|
|
||||||
case 'sqlite':
|
|
||||||
return new SQLiteFormatter(sequelize);
|
|
||||||
case 'postgres':
|
|
||||||
return new PostgresFormatter(sequelize);
|
|
||||||
case 'mysql':
|
|
||||||
case 'mariadb':
|
|
||||||
return new MySQLFormatter(sequelize);
|
|
||||||
}
|
|
||||||
};
|
|
@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* This file is part of the NocoBase (R) project.
|
||||||
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||||
|
* Authors: NocoBase Team.
|
||||||
|
*
|
||||||
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Col, Formatter } from './formatter';
|
||||||
|
|
||||||
|
export class OracleFormatter extends Formatter {
|
||||||
|
convertFormat(format: string) {
|
||||||
|
return format.replace(/hh/g, 'HH24').replace(/mm/g, 'MI').replace(/ss/g, 'SS');
|
||||||
|
}
|
||||||
|
formatDate(field: Col, format: string, timezoneOffset?: string) {
|
||||||
|
format = this.convertFormat(format);
|
||||||
|
const timezone = this.getTimezoneByOffset(timezoneOffset);
|
||||||
|
if (timezone) {
|
||||||
|
const col = this.sequelize.getQueryInterface().quoteIdentifiers((field as Col).col);
|
||||||
|
const fieldWithTZ = this.sequelize.literal(`(${col} AT TIME ZONE '${timezone}')`);
|
||||||
|
return this.sequelize.fn('to_char', fieldWithTZ, format);
|
||||||
|
}
|
||||||
|
return this.sequelize.fn('to_char', field, format);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatUnixTimeStamp(
|
||||||
|
field: string,
|
||||||
|
format: string,
|
||||||
|
accuracy: 'second' | 'millisecond' = 'second',
|
||||||
|
timezoneOffset?: string,
|
||||||
|
) {
|
||||||
|
format = this.convertFormat(format);
|
||||||
|
const col = this.sequelize.getQueryInterface().quoteIdentifiers(field);
|
||||||
|
const timezone = this.getTimezoneByOffset(timezoneOffset);
|
||||||
|
if (timezone) {
|
||||||
|
if (accuracy === 'millisecond') {
|
||||||
|
return this.sequelize.fn(
|
||||||
|
'to_char',
|
||||||
|
this.sequelize.literal(`to_timestamp(ROUND(${col} / 1000)) AT TIME ZONE '${timezone}'`),
|
||||||
|
format,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.sequelize.fn(
|
||||||
|
'to_char',
|
||||||
|
this.sequelize.literal(`to_timestamp(${col}) AT TIME ZONE '${timezone}'`),
|
||||||
|
format,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (accuracy === 'millisecond') {
|
||||||
|
return this.sequelize.fn('to_char', this.sequelize.literal(`to_timestamp(ROUND(${col} / 1000)`), format);
|
||||||
|
}
|
||||||
|
return this.sequelize.fn('to_char', this.sequelize.literal(`to_timestamp(${col})`), format);
|
||||||
|
}
|
||||||
|
}
|
@ -10,7 +10,6 @@
|
|||||||
import { Cache } from '@nocobase/cache';
|
import { Cache } from '@nocobase/cache';
|
||||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||||
import { query } from './actions/query';
|
import { query } from './actions/query';
|
||||||
import { resolve } from 'path';
|
|
||||||
|
|
||||||
export class PluginDataVisualizationServer extends Plugin {
|
export class PluginDataVisualizationServer extends Plugin {
|
||||||
cache: Cache;
|
cache: Cache;
|
||||||
@ -18,7 +17,7 @@ export class PluginDataVisualizationServer extends Plugin {
|
|||||||
afterAdd() {}
|
afterAdd() {}
|
||||||
|
|
||||||
beforeLoad() {
|
beforeLoad() {
|
||||||
this.app.resource({
|
this.app.resourceManager.define({
|
||||||
name: 'charts',
|
name: 'charts',
|
||||||
actions: {
|
actions: {
|
||||||
query,
|
query,
|
||||||
|
@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* This file is part of the NocoBase (R) project.
|
||||||
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||||
|
* Authors: NocoBase Team.
|
||||||
|
*
|
||||||
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Database } from '@nocobase/database';
|
||||||
|
import { SQLiteQueryParser } from './sqlite-query-parser';
|
||||||
|
import { PostgresQueryParser } from './postgres-query-parser';
|
||||||
|
import { MySQLQueryParser } from './mysql-query-parser';
|
||||||
|
import { QueryParser } from './query-parser';
|
||||||
|
import { OracleQueryParser } from './oracle-query-parser';
|
||||||
|
|
||||||
|
export const createQueryParser = (db: Database) => {
|
||||||
|
const dialect = db.sequelize.getDialect();
|
||||||
|
switch (dialect) {
|
||||||
|
case 'sqlite':
|
||||||
|
return new SQLiteQueryParser(db);
|
||||||
|
case 'postgres':
|
||||||
|
return new PostgresQueryParser(db);
|
||||||
|
case 'mysql':
|
||||||
|
case 'mariadb':
|
||||||
|
return new MySQLQueryParser(db);
|
||||||
|
case 'oracle':
|
||||||
|
return new OracleQueryParser(db);
|
||||||
|
default:
|
||||||
|
return new QueryParser(db);
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* This file is part of the NocoBase (R) project.
|
||||||
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||||
|
* Authors: NocoBase Team.
|
||||||
|
*
|
||||||
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Database } from '@nocobase/database';
|
||||||
|
import { MySQLFormatter } from '../formatter/mysql-formatter';
|
||||||
|
import { QueryParser } from './query-parser';
|
||||||
|
|
||||||
|
export class MySQLQueryParser extends QueryParser {
|
||||||
|
declare formatter: MySQLFormatter;
|
||||||
|
|
||||||
|
constructor(db: Database) {
|
||||||
|
super(db);
|
||||||
|
this.formatter = new MySQLFormatter(db.sequelize);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* This file is part of the NocoBase (R) project.
|
||||||
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||||
|
* Authors: NocoBase Team.
|
||||||
|
*
|
||||||
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { QueryParser } from './query-parser';
|
||||||
|
import { OrderProps, QueryParams } from '../types';
|
||||||
|
import { Context } from '@nocobase/actions';
|
||||||
|
import { OracleFormatter } from '../formatter/oracle-formatter';
|
||||||
|
import { Database } from '@nocobase/database';
|
||||||
|
|
||||||
|
export class OracleQueryParser extends QueryParser {
|
||||||
|
declare formatter: OracleFormatter;
|
||||||
|
|
||||||
|
constructor(db: Database) {
|
||||||
|
super(db);
|
||||||
|
this.formatter = new OracleFormatter(db.sequelize);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseOrders(ctx: Context, orders: OrderProps[], hasAgg: boolean) {
|
||||||
|
const { collection: collectionName, dimensions } = ctx.action.params.values as QueryParams;
|
||||||
|
const collection = this.db.getCollection(collectionName);
|
||||||
|
if (!orders.length) {
|
||||||
|
if (dimensions.length) {
|
||||||
|
orders.push(dimensions[0]);
|
||||||
|
} else {
|
||||||
|
let filterTks = collection.filterTargetKey;
|
||||||
|
if (!Array.isArray(filterTks)) {
|
||||||
|
filterTks = [filterTks];
|
||||||
|
}
|
||||||
|
orders.push(...filterTks.map((field) => ({ field, alias: field })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const order = super.parseOrders(ctx, orders, hasAgg);
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* This file is part of the NocoBase (R) project.
|
||||||
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||||
|
* Authors: NocoBase Team.
|
||||||
|
*
|
||||||
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Database } from '@nocobase/database';
|
||||||
|
import { PostgresFormatter } from '../formatter/postgres-formatter';
|
||||||
|
import { QueryParser } from './query-parser';
|
||||||
|
|
||||||
|
export class PostgresQueryParser extends QueryParser {
|
||||||
|
declare formatter: PostgresFormatter;
|
||||||
|
|
||||||
|
constructor(db: Database) {
|
||||||
|
super(db);
|
||||||
|
this.formatter = new PostgresFormatter(db.sequelize);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* This file is part of the NocoBase (R) project.
|
||||||
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||||
|
* Authors: NocoBase Team.
|
||||||
|
*
|
||||||
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Context, Next } from '@nocobase/actions';
|
||||||
|
import { DimensionProps, MeasureProps, OrderProps } from '../types';
|
||||||
|
import { Formatter } from '../formatter/formatter';
|
||||||
|
import { Database } from '@nocobase/database';
|
||||||
|
|
||||||
|
const AllowedAggFuncs = ['sum', 'count', 'avg', 'min', 'max'];
|
||||||
|
|
||||||
|
export class QueryParser {
|
||||||
|
db: Database;
|
||||||
|
formatter: Formatter;
|
||||||
|
|
||||||
|
constructor(db: Database) {
|
||||||
|
this.db = db;
|
||||||
|
this.formatter = {
|
||||||
|
format: ({ field }) => db.sequelize.col(field),
|
||||||
|
} as Formatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseMeasures(ctx: Context, measures: MeasureProps[]) {
|
||||||
|
let hasAgg = false;
|
||||||
|
const sequelize = this.db.sequelize;
|
||||||
|
const attributes = [];
|
||||||
|
const fieldMap = {};
|
||||||
|
measures.forEach((measure: MeasureProps & { field: string }) => {
|
||||||
|
const { field, aggregation, alias, distinct } = measure;
|
||||||
|
const attribute = [];
|
||||||
|
const col = sequelize.col(field);
|
||||||
|
if (aggregation) {
|
||||||
|
if (!AllowedAggFuncs.includes(aggregation)) {
|
||||||
|
throw new Error(`Invalid aggregation function: ${aggregation}`);
|
||||||
|
}
|
||||||
|
hasAgg = true;
|
||||||
|
attribute.push(sequelize.fn(aggregation, distinct ? sequelize.fn('DISTINCT', col) : col));
|
||||||
|
} else {
|
||||||
|
attribute.push(col);
|
||||||
|
}
|
||||||
|
if (alias) {
|
||||||
|
attribute.push(alias);
|
||||||
|
}
|
||||||
|
attributes.push(attribute.length > 1 ? attribute : attribute[0]);
|
||||||
|
fieldMap[alias || field] = measure;
|
||||||
|
});
|
||||||
|
return { attributes, fieldMap, hasAgg };
|
||||||
|
}
|
||||||
|
|
||||||
|
parseDimensions(ctx: Context, dimensions: (DimensionProps & { field: string })[], hasAgg: boolean, timezone: string) {
|
||||||
|
const sequelize = this.db.sequelize;
|
||||||
|
const attributes = [];
|
||||||
|
const group = [];
|
||||||
|
const fieldMap = {};
|
||||||
|
dimensions.forEach((dimension: DimensionProps & { field: string }) => {
|
||||||
|
const { field, format, alias, type, options } = dimension;
|
||||||
|
const attribute = [];
|
||||||
|
const col = sequelize.col(field);
|
||||||
|
if (format) {
|
||||||
|
attribute.push(this.formatter.format({ type, field, format, timezone, options }));
|
||||||
|
} else {
|
||||||
|
attribute.push(col);
|
||||||
|
}
|
||||||
|
if (alias) {
|
||||||
|
attribute.push(alias);
|
||||||
|
}
|
||||||
|
attributes.push(attribute.length > 1 ? attribute : attribute[0]);
|
||||||
|
if (hasAgg) {
|
||||||
|
group.push(attribute[0]);
|
||||||
|
}
|
||||||
|
fieldMap[alias || field] = dimension;
|
||||||
|
});
|
||||||
|
return { attributes, group, fieldMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
parseOrders(ctx: Context, orders: OrderProps[], hasAgg: boolean) {
|
||||||
|
const sequelize = this.db.sequelize;
|
||||||
|
const order = [];
|
||||||
|
orders.forEach((item: OrderProps) => {
|
||||||
|
const alias = sequelize.getQueryInterface().quoteIdentifier(item.alias);
|
||||||
|
const name = hasAgg ? sequelize.literal(alias) : sequelize.col(item.field as string);
|
||||||
|
order.push([name, item.order || 'ASC']);
|
||||||
|
});
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
parse() {
|
||||||
|
return async (ctx: Context, next: Next) => {
|
||||||
|
const { measures, dimensions, orders, include, where, limit } = ctx.action.params.values;
|
||||||
|
const { attributes: measureAttributes, fieldMap: measureFieldMap, hasAgg } = this.parseMeasures(ctx, measures);
|
||||||
|
const {
|
||||||
|
attributes: dimensionAttributes,
|
||||||
|
group,
|
||||||
|
fieldMap: dimensionFieldMap,
|
||||||
|
} = this.parseDimensions(ctx, dimensions, hasAgg, ctx.timezone);
|
||||||
|
const order = this.parseOrders(ctx, orders, hasAgg);
|
||||||
|
|
||||||
|
ctx.action.params.values = {
|
||||||
|
...ctx.action.params.values,
|
||||||
|
queryParams: {
|
||||||
|
where,
|
||||||
|
attributes: [...measureAttributes, ...dimensionAttributes],
|
||||||
|
include,
|
||||||
|
group,
|
||||||
|
order,
|
||||||
|
limit: limit || 2000,
|
||||||
|
subQuery: false,
|
||||||
|
raw: true,
|
||||||
|
},
|
||||||
|
fieldMap: { ...measureFieldMap, ...dimensionFieldMap },
|
||||||
|
};
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* This file is part of the NocoBase (R) project.
|
||||||
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||||
|
* Authors: NocoBase Team.
|
||||||
|
*
|
||||||
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Database } from '@nocobase/database';
|
||||||
|
import { SQLiteFormatter } from '../formatter/sqlite-formatter';
|
||||||
|
import { QueryParser } from './query-parser';
|
||||||
|
|
||||||
|
export class SQLiteQueryParser extends QueryParser {
|
||||||
|
declare formatter: SQLiteFormatter;
|
||||||
|
|
||||||
|
constructor(db: Database) {
|
||||||
|
super(db);
|
||||||
|
this.formatter = new SQLiteFormatter(db.sequelize);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* This file is part of the NocoBase (R) project.
|
||||||
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||||
|
* Authors: NocoBase Team.
|
||||||
|
*
|
||||||
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MeasureProps = {
|
||||||
|
field: string | string[];
|
||||||
|
type?: string;
|
||||||
|
aggregation?: string;
|
||||||
|
alias?: string;
|
||||||
|
distinct?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DimensionProps = {
|
||||||
|
field: string | string[];
|
||||||
|
type?: string;
|
||||||
|
alias?: string;
|
||||||
|
format?: string;
|
||||||
|
options?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderProps = {
|
||||||
|
field: string | string[];
|
||||||
|
alias?: string;
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QueryParams = Partial<{
|
||||||
|
uid: string;
|
||||||
|
dataSource: string;
|
||||||
|
collection: string;
|
||||||
|
measures: MeasureProps[];
|
||||||
|
dimensions: DimensionProps[];
|
||||||
|
orders: OrderProps[];
|
||||||
|
filter: any;
|
||||||
|
limit: number;
|
||||||
|
sql: {
|
||||||
|
fields?: string;
|
||||||
|
clauses?: string;
|
||||||
|
};
|
||||||
|
cache: {
|
||||||
|
enabled: boolean;
|
||||||
|
ttl: number;
|
||||||
|
};
|
||||||
|
// Get the latest data from the database
|
||||||
|
refresh: boolean;
|
||||||
|
}>;
|
Loading…
x
Reference in New Issue
Block a user