Skip to content

Commit 898d4d0

Browse files
authored
feat: Support custom numeric formatting (#10213)
1 parent 8e18659 commit 898d4d0

File tree

11 files changed

+383
-22
lines changed

11 files changed

+383
-22
lines changed

packages/cubejs-api-gateway/openspec.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,12 +305,27 @@ components:
305305
required:
306306
- type
307307
- value
308+
V1CubeMetaCustomNumericFormat:
309+
type: "object"
310+
description: Custom numeric format for numeric measures and dimensions
311+
properties:
312+
type:
313+
type: "string"
314+
enum: ["custom-numeric"]
315+
description: Type of the format (must be 'custom-numeric')
316+
value:
317+
type: "string"
318+
description: "d3-format specifier string (e.g., '.2f', ',.0f', '$,.2f', '.0%', '.2s'). See https://d3js.org/d3-format"
319+
required:
320+
- type
321+
- value
308322
V1CubeMetaFormat:
309323
oneOf:
310324
- $ref: "#/components/schemas/V1CubeMetaSimpleFormat"
311325
- $ref: "#/components/schemas/V1CubeMetaLinkFormat"
312326
- $ref: "#/components/schemas/V1CubeMetaCustomTimeFormat"
313-
description: Format of dimension - can be a simple string format, a link configuration, or a custom time format
327+
- $ref: "#/components/schemas/V1CubeMetaCustomNumericFormat"
328+
description: Format of measure or dimension - can be a simple string format, a link configuration, a custom time format, or a custom numeric format
314329
V1MetaResponse:
315330
type: "object"
316331
properties:

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ export type GranularityAnnotation = {
1616
};
1717

1818
export type DimensionCustomTimeFormat = { type: 'custom-time'; value: string };
19+
export type CustomNumericFormat = { type: 'custom-numeric'; value: string };
1920
export type DimensionLinkFormat = { type: 'link'; label: string };
2021
export type DimensionFormat = 'percent' | 'currency' | 'number' | 'imageUrl' | 'id' | 'link'
21-
| DimensionLinkFormat | DimensionCustomTimeFormat;
22+
| DimensionLinkFormat | DimensionCustomTimeFormat | CustomNumericFormat;
23+
export type MeasureFormat = 'percent' | 'currency' | 'number' | CustomNumericFormat;
2224

2325
export type Annotation = {
2426
title: string;

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

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import type { JoinGraph } from './JoinGraph';
1919
import type { ErrorReporter } from './ErrorReporter';
2020
import { CompilerInterface } from './PrepareCompiler';
2121

22+
export type CustomNumericFormat = { type: 'custom-numeric'; value: string };
23+
export type DimensionCustomTimeFormat = { type: 'custom-time'; value: string };
24+
export type DimensionLinkFormat = { type: 'link'; label?: string };
25+
export type DimensionFormat = string | DimensionLinkFormat | DimensionCustomTimeFormat | CustomNumericFormat;
26+
export type MeasureFormat = string | CustomNumericFormat;
27+
2228
// Extended types for cube symbols with all runtime properties
2329
export interface ExtendedCubeSymbolDefinition extends CubeSymbolDefinition {
2430
description?: string;
@@ -61,7 +67,7 @@ export type MeasureConfig = {
6167
title: string;
6268
description?: string;
6369
shortTitle: string;
64-
format?: string;
70+
format?: MeasureFormat;
6571
cumulativeTotal: boolean;
6672
cumulative: boolean;
6773
type: string;
@@ -77,10 +83,6 @@ export type MeasureConfig = {
7783
public: boolean;
7884
};
7985

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-
8486
export type DimensionConfig = {
8587
name: string;
8688
title: string;
@@ -372,7 +374,7 @@ export class CubeToMetaTransformer implements CompilerInterface {
372374
title: this.title(cubeTitle, nameToMetric, false),
373375
description: extendedMetricDef.description,
374376
shortTitle: this.title(cubeTitle, nameToMetric, true),
375-
format: extendedMetricDef.format,
377+
format: this.transformMeasureFormat(extendedMetricDef.format),
376378
cumulativeTotal: isCumulative,
377379
cumulative: isCumulative,
378380
type,
@@ -396,21 +398,39 @@ export class CubeToMetaTransformer implements CompilerInterface {
396398
}
397399

398400
private transformDimensionFormat({ format, type }: ExtendedCubeSymbolDefinition): DimensionFormat | undefined {
399-
if (!format || type !== 'time') {
401+
if (!format || typeof format === 'object') {
400402
return format;
401403
}
402404

403-
if (typeof format === 'object') {
405+
const standardFormats = ['imageUrl', 'currency', 'percent', 'number', 'id'];
406+
if (standardFormats.includes(format)) {
404407
return format;
405408
}
406409

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+
// Custom time format for time dimensions
411+
if (type === 'time') {
412+
return { type: 'custom-time', value: format };
413+
}
414+
415+
// Custom numeric format for number dimensions
416+
if (type === 'number') {
417+
return { type: 'custom-numeric', value: format };
418+
}
419+
420+
return format;
421+
}
422+
423+
private transformMeasureFormat(format: string | undefined): MeasureFormat | undefined {
424+
if (!format) {
425+
return undefined;
426+
}
427+
428+
const standardFormats = ['percent', 'currency', 'number'];
410429
if (standardFormats.includes(format)) {
411430
return format;
412431
}
413432

414-
return { type: 'custom-time', value: format };
433+
// Custom numeric format
434+
return { type: 'custom-numeric', value: format };
415435
}
416436
}

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

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,82 @@ const timeFormatSchema = Joi.alternatives([
182182
customTimeFormatSchema
183183
]);
184184

185+
// d3-format specification (Python format spec mini-language)
186+
// See: https://d3js.org/d3-format
187+
// See: https://docs.python.org/3/library/string.html#format-specification-mini-language
188+
// Format specifier: [[fill]align][sign][symbol][0][width][,][.precision][~][type]
189+
const NUMERIC_FORMAT_TYPES = new Set([
190+
'e', // exponent notation
191+
'f', // fixed-point notation
192+
'g', // either decimal or exponent notation
193+
'r', // decimal notation, rounded to significant digits
194+
's', // decimal notation with an SI prefix
195+
'%', // multiply by 100 and format as percentage
196+
'p', // multiply by 100, round to significant digits, and format as percentage
197+
'b', // binary notation
198+
'o', // octal notation
199+
'd', // decimal notation (integer)
200+
'x', // lowercase hexadecimal notation
201+
'X', // uppercase hexadecimal notation
202+
'c', // character data
203+
'n', // like g, but with locale-specific thousand separator
204+
]);
205+
206+
// d3-format specifier: [[fill]align][sign][symbol][0][width][,][.precision][~][type]
207+
// Regex breakdown:
208+
// (?:(.)?([<>=^]))? - optional fill (any char) + align (<>=^)
209+
// ([+\-( ])? - optional sign (+, -, (, or space)
210+
// ([$#])? - optional symbol ($ or #)
211+
// (0)? - optional zero flag
212+
// (\d+)? - optional width (positive integer)
213+
// (,)? - optional comma flag (grouping)
214+
// (?:\.(\d+))? - optional precision (.N where N is non-negative integer)
215+
// (~)? - optional tilde (trim insignificant zeros)
216+
// ([a-zA-Z%])? - optional type character
217+
const NUMERIC_FORMAT_REGEX = /^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(?:\.(\d+))?(~)?([a-zA-Z%])?$/;
218+
219+
const customNumericFormatSchema = Joi.string().custom((value, helper) => {
220+
const match = value.match(NUMERIC_FORMAT_REGEX);
221+
if (!match) {
222+
return helper.message({
223+
custom: `Invalid numeric format "${value}". Must be a valid d3-format specifier (e.g., ".2f", ",.0f", "$,.2f", ".0%", ".2s")`
224+
});
225+
}
226+
227+
const [, fill, align, sign, symbol, zero, width, comma, precision, tilde, type] = match;
228+
229+
if (fill && !align) {
230+
return helper.message({
231+
custom: `Invalid numeric format "${value}". Fill character requires alignment specifier (<, >, =, or ^)`
232+
});
233+
}
234+
235+
if (type && !NUMERIC_FORMAT_TYPES.has(type.toLowerCase())) {
236+
return helper.message({
237+
custom: `Invalid numeric format "${value}". Unknown type character '${type}'. Valid types: ${[...NUMERIC_FORMAT_TYPES].join(', ')}`
238+
});
239+
}
240+
241+
// Validate that the format is not empty (must have at least something meaningful)
242+
if (!sign && !symbol && !zero && !width && !comma && precision === undefined && !tilde && !type) {
243+
return helper.message({
244+
custom: `Invalid numeric format "${value}". Format must contain at least one specifier (e.g., type, precision, comma, sign, symbol)`
245+
});
246+
}
247+
248+
return value;
249+
});
250+
251+
const measureFormatSchema = Joi.alternatives([
252+
Joi.string().valid('percent', 'currency', 'number'),
253+
customNumericFormatSchema
254+
]);
255+
256+
const dimensionNumericFormatSchema = Joi.alternatives([
257+
formatSchema,
258+
customNumericFormatSchema
259+
]);
260+
185261
const BaseDimensionWithoutSubQuery = {
186262
aliases: Joi.array().items(Joi.string()),
187263
type: Joi.any().valid('string', 'number', 'boolean', 'time', 'geo').required(),
@@ -195,8 +271,10 @@ const BaseDimensionWithoutSubQuery = {
195271
suggestFilterValues: Joi.boolean().strict(),
196272
enableSuggestions: Joi.boolean().strict(),
197273
format: Joi.when('type', {
198-
is: 'time',
199-
then: timeFormatSchema,
274+
switch: [
275+
{ is: 'time', then: timeFormatSchema },
276+
{ is: 'number', then: dimensionNumericFormatSchema },
277+
],
200278
otherwise: formatSchema
201279
}),
202280
meta: Joi.any(),
@@ -302,7 +380,7 @@ const ToDate = {
302380

303381
const BaseMeasure = {
304382
aliases: Joi.array().items(Joi.string()),
305-
format: Joi.any().valid('percent', 'currency', 'number'),
383+
format: measureFormatSchema,
306384
public: Joi.boolean().strict(),
307385
// TODO: Deprecate and remove, please use public
308386
visible: Joi.boolean().strict(),

0 commit comments

Comments
 (0)