Skip to content

Commit 8e18659

Browse files
authored
feat: Support custom date formatting for time dimensions (#10204)
1 parent 32d50a5 commit 8e18659

File tree

10 files changed

+362
-7
lines changed

10 files changed

+362
-7
lines changed

packages/cubejs-api-gateway/openspec.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,11 +291,26 @@ components:
291291
required:
292292
- label
293293
- type
294+
V1CubeMetaCustomTimeFormat:
295+
type: "object"
296+
description: Custom time format for time dimensions
297+
properties:
298+
type:
299+
type: "string"
300+
enum: ["custom-time"]
301+
description: Type of the format (must be 'custom-time')
302+
value:
303+
type: "string"
304+
description: "POSIX strftime format string (IEEE Std 1003.1 / POSIX.1) with d3-time-format extensions (e.g., '%Y-%m-%d', '%d/%m/%Y %H:%M:%S'). See https://pubs.opengroup.org/onlinepubs/009695399/functions/strptime.html and https://d3js.org/d3-time-format"
305+
required:
306+
- type
307+
- value
294308
V1CubeMetaFormat:
295309
oneOf:
296310
- $ref: "#/components/schemas/V1CubeMetaSimpleFormat"
297311
- $ref: "#/components/schemas/V1CubeMetaLinkFormat"
298-
description: Format of dimension - can be either a simple string format or an object with link configuration
312+
- $ref: "#/components/schemas/V1CubeMetaCustomTimeFormat"
313+
description: Format of dimension - can be a simple string format, a link configuration, or a custom time format
299314
V1MetaResponse:
300315
type: "object"
301316
properties:

packages/cubejs-client-core/src/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@ export type GranularityAnnotation = {
1515
origin?: string;
1616
};
1717

18+
export type DimensionCustomTimeFormat = { type: 'custom-time'; value: string };
19+
export type DimensionLinkFormat = { type: 'link'; label: string };
20+
export type DimensionFormat = 'percent' | 'currency' | 'number' | 'imageUrl' | 'id' | 'link'
21+
| DimensionLinkFormat | DimensionCustomTimeFormat;
22+
1823
export type Annotation = {
1924
title: string;
2025
shortTitle: string;
2126
type: string;
2227
meta?: any;
23-
format?: 'currency' | 'percent' | 'number';
28+
format?: DimensionFormat;
2429
drillMembers?: any[];
2530
drillMembersGrouped?: any;
2631
granularity?: GranularityAnnotation;
@@ -388,6 +393,7 @@ export type CubeTimeDimensionGranularity = {
388393
export type BaseCubeDimension = BaseCubeMember & {
389394
primaryKey?: boolean;
390395
suggestFilterValues: boolean;
396+
format?: DimensionFormat;
391397
};
392398

393399
export type CubeTimeDimension = BaseCubeDimension &

packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,18 @@ export type MeasureConfig = {
7777
public: boolean;
7878
};
7979

80+
export type DimensionCustomTimeFormat = { type: 'custom-time'; value: string };
81+
export type DimensionLinkFormat = { type: 'link'; label?: string };
82+
export type DimensionFormat = string | DimensionLinkFormat | DimensionCustomTimeFormat;
83+
8084
export type DimensionConfig = {
8185
name: string;
8286
title: string;
8387
type: string;
8488
description?: string;
8589
shortTitle: string;
8690
suggestFilterValues: boolean;
87-
format?: string;
91+
format?: DimensionFormat;
8892
meta?: any;
8993
isVisible: boolean;
9094
public: boolean;
@@ -252,7 +256,7 @@ export class CubeToMetaTransformer implements CompilerInterface {
252256
extendedDimDef.suggestFilterValues == null
253257
? true
254258
: extendedDimDef.suggestFilterValues,
255-
format: extendedDimDef.format,
259+
format: this.transformDimensionFormat(extendedDimDef),
256260
meta: extendedDimDef.meta,
257261
isVisible: dimensionVisibility,
258262
public: dimensionVisibility,
@@ -390,4 +394,23 @@ export class CubeToMetaTransformer implements CompilerInterface {
390394
private titleize(name: string): string {
391395
return inflection.titleize(inflection.underscore(camelCase(name, { pascalCase: true })));
392396
}
397+
398+
private transformDimensionFormat({ format, type }: ExtendedCubeSymbolDefinition): DimensionFormat | undefined {
399+
if (!format || type !== 'time') {
400+
return format;
401+
}
402+
403+
if (typeof format === 'object') {
404+
return format;
405+
}
406+
407+
// I don't know why, but we allow to define these formats for time dimensions.
408+
// TODO: Should we deprecate it?
409+
const standardFormats = ['imageUrl', 'currency', 'percent', 'number', 'id'];
410+
if (standardFormats.includes(format)) {
411+
return format;
412+
}
413+
414+
return { type: 'custom-time', value: format };
415+
}
393416
}

packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,69 @@ const formatSchema = Joi.alternatives([
119119
})
120120
]);
121121

122+
// POSIX strftime specification (IEEE Std 1003.1 / POSIX.1) with d3-time-format extensions
123+
// See: https://pubs.opengroup.org/onlinepubs/009695399/functions/strptime.html
124+
// See: https://d3js.org/d3-time-format
125+
const TIME_SPECIFIERS = new Set([
126+
// POSIX standard specifiers
127+
'a', 'A', 'b', 'B', 'c', 'd', 'H', 'I', 'j', 'm',
128+
'M', 'n', 'p', 'S', 't', 'U', 'w', 'W', 'x', 'X',
129+
'y', 'Y', 'Z', '%',
130+
// d3-time-format extensions
131+
'e', // space-padded day of month
132+
'f', // microseconds
133+
'g', // ISO 8601 year without century
134+
'G', // ISO 8601 year with century
135+
'L', // milliseconds
136+
'q', // quarter
137+
'Q', // milliseconds since UNIX epoch
138+
's', // seconds since UNIX epoch
139+
'u', // Monday-based weekday [1,7]
140+
'V', // ISO 8601 week number
141+
]);
142+
143+
const customTimeFormatSchema = Joi.string().custom((value, helper) => {
144+
let hasSpecifier = false;
145+
let i = 0;
146+
147+
while (i < value.length) {
148+
if (value[i] === '%') {
149+
if (i + 1 >= value.length) {
150+
return helper.message({ custom: `Invalid time format "${value}". Incomplete specifier at end of string` });
151+
}
152+
153+
const specifier = value[i + 1];
154+
155+
if (!TIME_SPECIFIERS.has(specifier)) {
156+
return helper.message({ custom: `Invalid time format "${value}". Unknown specifier '%${specifier}'` });
157+
}
158+
159+
// %% is an escape for literal %, not a date/time specifier
160+
if (specifier !== '%') {
161+
hasSpecifier = true;
162+
}
163+
164+
i += 2;
165+
} else {
166+
// Any other character is treated as literal text
167+
i++;
168+
}
169+
}
170+
171+
if (!hasSpecifier) {
172+
return helper.message({
173+
custom: `Invalid strptime format "${value}". Format must contain at least one strptime specifier (e.g., %Y, %m, %d)`
174+
});
175+
}
176+
177+
return value;
178+
});
179+
180+
const timeFormatSchema = Joi.alternatives([
181+
formatSchema,
182+
customTimeFormatSchema
183+
]);
184+
122185
const BaseDimensionWithoutSubQuery = {
123186
aliases: Joi.array().items(Joi.string()),
124187
type: Joi.any().valid('string', 'number', 'boolean', 'time', 'geo').required(),
@@ -131,7 +194,11 @@ const BaseDimensionWithoutSubQuery = {
131194
description: Joi.string(),
132195
suggestFilterValues: Joi.boolean().strict(),
133196
enableSuggestions: Joi.boolean().strict(),
134-
format: formatSchema,
197+
format: Joi.when('type', {
198+
is: 'time',
199+
then: timeFormatSchema,
200+
otherwise: formatSchema
201+
}),
135202
meta: Joi.any(),
136203
values: Joi.when('type', {
137204
is: 'switch',

packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,4 +1292,196 @@ describe('Cube Validation', () => {
12921292
expect(result.error).toBeTruthy();
12931293
});
12941294
});
1295+
1296+
describe('Custom time format for time dimensions (strptime)', () => {
1297+
it('time dimension with valid strptime format - correct', async () => {
1298+
const cubeValidator = new CubeValidator(new CubeSymbols());
1299+
const cube = {
1300+
name: 'name',
1301+
sql: () => 'SELECT * FROM public.Orders',
1302+
dimensions: {
1303+
createdAt: {
1304+
sql: () => 'created_at',
1305+
type: 'time',
1306+
format: '%Y-%m-%d'
1307+
},
1308+
},
1309+
fileName: 'fileName',
1310+
};
1311+
1312+
const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
1313+
expect(validationResult.error).toBeFalsy();
1314+
});
1315+
1316+
it('time dimension with complex strptime format - correct', async () => {
1317+
const cubeValidator = new CubeValidator(new CubeSymbols());
1318+
const cube = {
1319+
name: 'name',
1320+
sql: () => 'SELECT * FROM public.Orders',
1321+
dimensions: {
1322+
createdAt: {
1323+
sql: () => 'created_at',
1324+
type: 'time',
1325+
format: '%d/%m/%Y %H:%M:%S'
1326+
},
1327+
},
1328+
fileName: 'fileName',
1329+
};
1330+
1331+
const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
1332+
expect(validationResult.error).toBeFalsy();
1333+
});
1334+
1335+
it('time dimension with literal text in format - correct', async () => {
1336+
const cubeValidator = new CubeValidator(new CubeSymbols());
1337+
const cube = {
1338+
name: 'name',
1339+
sql: () => 'SELECT * FROM public.Orders',
1340+
dimensions: {
1341+
createdAt: {
1342+
sql: () => 'created_at',
1343+
type: 'time',
1344+
format: '%Y Year %m Month'
1345+
},
1346+
},
1347+
fileName: 'fileName',
1348+
};
1349+
1350+
const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
1351+
expect(validationResult.error).toBeFalsy();
1352+
});
1353+
1354+
it('time dimension with escaped percent - correct', async () => {
1355+
const cubeValidator = new CubeValidator(new CubeSymbols());
1356+
const cube = {
1357+
name: 'name',
1358+
sql: () => 'SELECT * FROM public.Orders',
1359+
dimensions: {
1360+
createdAt: {
1361+
sql: () => 'created_at',
1362+
type: 'time',
1363+
format: '%Y-%m-%d %%'
1364+
},
1365+
},
1366+
fileName: 'fileName',
1367+
};
1368+
1369+
const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
1370+
expect(validationResult.error).toBeFalsy();
1371+
});
1372+
1373+
it('time dimension with standard format - correct', async () => {
1374+
const cubeValidator = new CubeValidator(new CubeSymbols());
1375+
const cube = {
1376+
name: 'name',
1377+
sql: () => 'SELECT * FROM public.Orders',
1378+
dimensions: {
1379+
createdAt: {
1380+
sql: () => 'created_at',
1381+
type: 'time',
1382+
format: 'id'
1383+
},
1384+
},
1385+
fileName: 'fileName',
1386+
};
1387+
1388+
const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
1389+
expect(validationResult.error).toBeFalsy();
1390+
});
1391+
1392+
it('time dimension with invalid format (no specifiers) - error', async () => {
1393+
const cubeValidator = new CubeValidator(new CubeSymbols());
1394+
const cube = {
1395+
name: 'name',
1396+
sql: () => 'SELECT * FROM public.Orders',
1397+
dimensions: {
1398+
createdAt: {
1399+
sql: () => 'created_at',
1400+
type: 'time',
1401+
format: 'invalid'
1402+
},
1403+
},
1404+
fileName: 'fileName',
1405+
};
1406+
1407+
const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
1408+
expect(validationResult.error).toBeTruthy();
1409+
});
1410+
1411+
it('time dimension with invalid specifier - error', async () => {
1412+
const cubeValidator = new CubeValidator(new CubeSymbols());
1413+
const cube = {
1414+
name: 'name',
1415+
sql: () => 'SELECT * FROM public.Orders',
1416+
dimensions: {
1417+
createdAt: {
1418+
sql: () => 'created_at',
1419+
type: 'time',
1420+
format: '%Y-%K-%d'
1421+
},
1422+
},
1423+
fileName: 'fileName',
1424+
};
1425+
1426+
const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
1427+
expect(validationResult.error).toBeTruthy();
1428+
});
1429+
1430+
it('time dimension with incomplete specifier at end - error', async () => {
1431+
const cubeValidator = new CubeValidator(new CubeSymbols());
1432+
const cube = {
1433+
name: 'name',
1434+
sql: () => 'SELECT * FROM public.Orders',
1435+
dimensions: {
1436+
createdAt: {
1437+
sql: () => 'created_at',
1438+
type: 'time',
1439+
format: '%Y-%m-%'
1440+
},
1441+
},
1442+
fileName: 'fileName',
1443+
};
1444+
1445+
const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
1446+
expect(validationResult.error).toBeTruthy();
1447+
});
1448+
1449+
it('time dimension with only escaped percent - error', async () => {
1450+
const cubeValidator = new CubeValidator(new CubeSymbols());
1451+
const cube = {
1452+
name: 'name',
1453+
sql: () => 'SELECT * FROM public.Orders',
1454+
dimensions: {
1455+
createdAt: {
1456+
sql: () => 'created_at',
1457+
type: 'time',
1458+
format: '%%'
1459+
},
1460+
},
1461+
fileName: 'fileName',
1462+
};
1463+
1464+
const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
1465+
expect(validationResult.error).toBeTruthy();
1466+
});
1467+
1468+
it('non-time dimension with strptime format string - error', async () => {
1469+
const cubeValidator = new CubeValidator(new CubeSymbols());
1470+
const cube = {
1471+
name: 'name',
1472+
sql: () => 'SELECT * FROM public.Orders',
1473+
dimensions: {
1474+
status: {
1475+
sql: () => 'status',
1476+
type: 'string',
1477+
format: '%Y-%m-%d'
1478+
},
1479+
},
1480+
fileName: 'fileName',
1481+
};
1482+
1483+
const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
1484+
expect(validationResult.error).toBeTruthy();
1485+
});
1486+
});
12951487
});

rust/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.idea

rust/cubesql/cubeclient/.openapi-generator/FILES

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
src/lib.rs
33
src/models/mod.rs
44
src/models/v1_cube_meta.rs
5+
src/models/v1_cube_meta_custom_time_format.rs
56
src/models/v1_cube_meta_dimension.rs
67
src/models/v1_cube_meta_dimension_granularity.rs
78
src/models/v1_cube_meta_folder.rs

0 commit comments

Comments
 (0)