From afdff961e42138f89bd0b22cd40209021593433b Mon Sep 17 00:00:00 2001 From: YANG QIA <2013xile@gmail.com> Date: Fri, 17 May 2024 12:39:56 +0800 Subject: [PATCH] fix(data-vi): should use local timezone when formatting date (#4366) * fix(data-vi): should use local timezone when formatting date * fix: mysql * chore: remove only --- .../src/server/__tests__/api.test.ts | 72 +++++++++++++++++-- .../src/server/__tests__/formatter.test.ts | 12 ++-- .../src/server/__tests__/query.test.ts | 28 ++++---- .../src/server/actions/formatter.ts | 43 +++++++++-- .../src/server/actions/query.ts | 2 +- 5 files changed, 129 insertions(+), 28 deletions(-) diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/api.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/api.test.ts index 9bfeca5aa..6ca72cdd1 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/api.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/api.test.ts @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Database } from '@nocobase/database'; +import { Database, Repository } from '@nocobase/database'; import { MockServer, createMockServer } from '@nocobase/test'; import compose from 'koa-compose'; import { parseBuilder, parseFieldAndAssociations, queryData } from '../actions/query'; @@ -15,6 +15,7 @@ import { parseBuilder, parseFieldAndAssociations, queryData } from '../actions/q describe('api', () => { let app: MockServer; let db: Database; + let repo: Repository; beforeAll(async () => { app = await createMockServer({ @@ -26,6 +27,10 @@ describe('api', () => { db.collection({ name: 'chart_test', fields: [ + { + type: 'bigInt', + name: 'id', + }, { type: 'double', name: 'price', @@ -45,11 +50,11 @@ describe('api', () => { ], }); await db.sync(); - const repo = db.getRepository('chart_test'); + repo = db.getRepository('chart_test'); await repo.create({ values: [ - { price: 1, count: 1, title: 'title1', createdAt: '2023-02-02' }, - { price: 2, count: 2, title: 'title2', createdAt: '2023-01-01' }, + { id: 1, price: 1, count: 1, title: 'title1', createdAt: '2023-02-02' }, + { id: 2, price: 2, count: 2, title: 'title2', createdAt: '2023-01-01' }, ], }); }); @@ -124,4 +129,63 @@ describe('api', () => { expect(ctx.action.params.values.data).toBeDefined(); expect(ctx.action.params.values.data).toMatchObject([{ createdAt: '2023-01' }, { createdAt: '2023-02' }]); }); + + test('datetime format with timezone', async () => { + const dialect = db.sequelize.getDialect(); + if (dialect === 'sqlite') { + await repo.create({ + values: { + id: 3, + createdAt: '2024-05-14 19:32:30.175 +00:00', + }, + }); + } else if (dialect === 'postgres') { + await repo.create({ + values: { + id: 3, + createdAt: '2024-05-14 19:32:30.175+00', + }, + }); + } else if (dialect === 'mysql' || dialect === 'mariadb') { + await repo.create({ + values: { + id: 3, + createdAt: '2024-05-14T19:32:30Z', + }, + }); + } else { + expect(true).toBe(true); + return; + } + const ctx = { + app, + db, + timezone: '+08:25', + action: { + params: { + values: { + collection: 'chart_test', + measures: [ + { + field: ['id'], + aggregation: 'count', + }, + ], + dimensions: [ + { + field: ['createdAt'], + format: 'YYYY-MM-DD', + }, + ], + filter: { + id: 3, + }, + }, + }, + }, + } as any; + await compose([parseFieldAndAssociations, parseBuilder, queryData])(ctx, async () => {}); + expect(ctx.action.params.values.data).toBeDefined(); + expect(ctx.action.params.values.data).toMatchObject([{ createdAt: '2024-05-15' }]); + }); }); diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/formatter.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/formatter.test.ts index 1f6d8e3e8..92ab2b356 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/formatter.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/formatter.test.ts @@ -21,8 +21,8 @@ describe('formatter', () => { }), col: (field: string) => field, getDialect: () => 'sqlite', - }; - const result = formatter(sequelize, 'datetime', field, format); + } as any; + const result = formatter(sequelize, 'datetime', field, format) as any; expect(result.format).toEqual('%Y-%m-%d %H:%M:%S'); }); @@ -35,8 +35,8 @@ describe('formatter', () => { }), col: (field: string) => field, getDialect: () => 'mysql', - }; - const result = formatter(sequelize, 'datetime', field, format); + } as any; + const result = formatter(sequelize, 'datetime', field, format) as any; expect(result.format).toEqual('%Y-%m-%d %H:%i:%S'); }); @@ -49,8 +49,8 @@ describe('formatter', () => { }), col: (field: string) => field, getDialect: () => 'postgres', - }; - const result = formatter(sequelize, 'datetime', field, format); + } as any; + const result = formatter(sequelize, 'datetime', field, format) as any; expect(result.format).toEqual('YYYY-MM-DD HH24:MI:SS'); }); }); diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts index 13e452494..33d1ad9b4 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts @@ -18,26 +18,22 @@ import { parseVariables, postProcess, } from '../actions/query'; +import { Database } from '@nocobase/database'; const formatter = await import('../actions/formatter'); describe('query', () => { describe('parseBuilder', () => { - const sequelize = { - fn: vi.fn().mockImplementation((fn: string, field: string) => [fn, field]), - col: vi.fn().mockImplementation((field: string) => field), - getDialect() { - return false; - }, - }; let ctx: any; let app: MockServer; + let db: Database; beforeAll(async () => { app = await createMockServer({ plugins: ['data-source-manager', 'users', 'acl'], }); - app.db.options.underscored = true; - app.db.collection({ + db = app.db; + db.options.underscored = true; + db.collection({ name: 'orders', fields: [ { @@ -63,9 +59,8 @@ describe('query', () => { }); ctx = { app, - db: app.db, + db, }; - ctx.db.sequelize = sequelize; }); it('should check permissions', async () => { @@ -141,6 +136,7 @@ describe('query', () => { ], }); }); + it('should parse measures', async () => { const measures1 = [ { @@ -159,7 +155,9 @@ describe('query', () => { }, }; await compose([parseFieldAndAssociations, parseBuilder])(context, async () => {}); - expect(context.action.params.values.queryParams.attributes).toEqual([['orders.price', 'price']]); + expect(context.action.params.values.queryParams.attributes).toEqual([ + [db.sequelize.col('orders.price'), 'price'], + ]); const measures2 = [ { field: ['price'], @@ -179,8 +177,11 @@ describe('query', () => { }, }; await compose([parseFieldAndAssociations, parseBuilder])(context2, async () => {}); - expect(context2.action.params.values.queryParams.attributes).toEqual([[['sum', 'orders.price'], 'price-alias']]); + expect(context2.action.params.values.queryParams.attributes).toEqual([ + [db.sequelize.fn('sum', db.sequelize.col('orders.price')), 'price-alias'], + ]); }); + it('should parse dimensions', async () => { vi.spyOn(formatter, 'formatter').mockReturnValue('formatted-field'); const dimensions = [ @@ -225,6 +226,7 @@ describe('query', () => { await compose([parseFieldAndAssociations, parseBuilder])(context2, async () => {}); expect(context2.action.params.values.queryParams.group).toEqual(['formatted-field']); }); + it('should parse filter', async () => { const filter = { createdAt: { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/formatter.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/formatter.ts index e69e3fda9..65d350b51 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/formatter.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/formatter.ts @@ -6,8 +6,25 @@ * 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'; -export const dateFormatFn = (sequelize: any, dialect: string, field: string, format: string) => { +const getOffsetMinutesFromTimezone = (timezone: string) => { + const sign = timezone.charAt(0); + timezone = timezone.slice(1); + const [hours, minutes] = timezone.split(':'); + const hoursNum = Number(hours); + const minutesNum = Number(minutes); + const offset = hoursNum * 60 + minutesNum; + return `${sign}${offset} minutes`; +}; + +export const dateFormatFn = ( + sequelize: Sequelize, + dialect: string, + field: string, + format: string, + timezone?: string, +) => { switch (dialect) { case 'sqlite': format = format @@ -17,6 +34,9 @@ export const dateFormatFn = (sequelize: any, dialect: string, field: string, for .replace(/hh/g, '%H') .replace(/mm/g, '%M') .replace(/ss/g, '%S'); + if (timezone) { + return sequelize.fn('strftime', format, sequelize.col(field), getOffsetMinutesFromTimezone(timezone)); + } return sequelize.fn('strftime', format, sequelize.col(field)); case 'mysql': case 'mariadb': @@ -27,9 +47,24 @@ export const dateFormatFn = (sequelize: any, dialect: string, field: string, for .replace(/hh/g, '%H') .replace(/mm/g, '%i') .replace(/ss/g, '%S'); + if (timezone) { + return sequelize.fn( + 'date_format', + sequelize.fn('convert_tz', sequelize.col(field), process.env.DB_TIMEZONE || '+00:00', timezone), + format, + ); + } return sequelize.fn('date_format', sequelize.col(field), format); case 'postgres': format = format.replace(/hh/g, 'HH24').replace(/mm/g, 'MI').replace(/ss/g, 'SS'); + if (timezone) { + const fieldWithTZ = sequelize.literal( + `(${sequelize + .getQueryInterface() + .quoteIdentifiers(field)} AT TIME ZONE CURRENT_SETTING('TIMEZONE') AT TIME ZONE '${timezone}')`, + ); + return sequelize.fn('to_char', fieldWithTZ, format); + } return sequelize.fn('to_char', sequelize.col(field), format); default: return sequelize.col(field); @@ -37,7 +72,7 @@ export const dateFormatFn = (sequelize: any, dialect: string, field: string, for }; /* istanbul ignore next -- @preserve */ -export const formatFn = (sequelize: any, dialect: string, field: string, format: string) => { +export const formatFn = (sequelize: Sequelize, dialect: string, field: string, format: string) => { switch (dialect) { case 'sqlite': case 'postgres': @@ -47,13 +82,13 @@ export const formatFn = (sequelize: any, dialect: string, field: string, format: } }; -export const formatter = (sequelize: any, type: string, field: string, format: string) => { +export const formatter = (sequelize: Sequelize, type: string, field: string, format: string, timezone?: string) => { const dialect = sequelize.getDialect(); switch (type) { case 'date': case 'datetime': case 'time': - return dateFormatFn(sequelize, dialect, field, format); + return dateFormatFn(sequelize, dialect, field, format, timezone); default: return formatFn(sequelize, dialect, field, format); } diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts index b67e18aec..d192b8dbb 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts @@ -136,7 +136,7 @@ export const parseBuilder = async (ctx: Context, next: Next) => { const attribute = []; const col = sequelize.col(field); if (format) { - attribute.push(formatter(sequelize, type, field, format)); + attribute.push(formatter(sequelize, type, field, format, ctx.timezone)); } else { attribute.push(col); }