Skip to content

Commit 971b9f6

Browse files
authored
feat(client-core): Introduce formating API (#10653)
1 parent 6f79820 commit 971b9f6

8 files changed

Lines changed: 447 additions & 1 deletion

File tree

packages/cubejs-client-core/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"dependencies": {
2121
"core-js": "^3.6.5",
2222
"cross-fetch": "^3.0.2",
23+
"d3-format": "^3.1.0",
24+
"d3-time-format": "^4.1.0",
2325
"dayjs": "^1.10.4",
2426
"ramda": "^0.27.2",
2527
"url-search-params-polyfill": "^7.0.0",
@@ -41,6 +43,8 @@
4143
"license": "MIT",
4244
"devDependencies": {
4345
"@cubejs-backend/linter": "1.6.32",
46+
"@types/d3-format": "^3",
47+
"@types/d3-time-format": "^4",
4448
"@types/moment-range": "^4.0.0",
4549
"@types/ramda": "^0.27.34",
4650
"@vitest/coverage-v8": "^4",
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { formatLocale } from 'd3-format';
2+
3+
import type { FormatLocaleDefinition, FormatLocaleObject } from 'd3-format';
4+
5+
import enUS from 'd3-format/locale/en-US.json';
6+
import enGB from 'd3-format/locale/en-GB.json';
7+
import zhCN from 'd3-format/locale/zh-CN.json';
8+
import esES from 'd3-format/locale/es-ES.json';
9+
import esMX from 'd3-format/locale/es-MX.json';
10+
import deDE from 'd3-format/locale/de-DE.json';
11+
import jaJP from 'd3-format/locale/ja-JP.json';
12+
import frFR from 'd3-format/locale/fr-FR.json';
13+
import ptBR from 'd3-format/locale/pt-BR.json';
14+
import koKR from 'd3-format/locale/ko-KR.json';
15+
import itIT from 'd3-format/locale/it-IT.json';
16+
import nlNL from 'd3-format/locale/nl-NL.json';
17+
import ruRU from 'd3-format/locale/ru-RU.json';
18+
19+
// Pre-built d3 locale definitions for the most popular locales.
20+
// Used as a fallback when Intl is unavailable (e.g. some edge runtimes).
21+
export const formatD3NumericLocale: Record<string, FormatLocaleDefinition> = {
22+
'en-US': enUS as unknown as FormatLocaleDefinition,
23+
'en-GB': enGB as unknown as FormatLocaleDefinition,
24+
'zh-CN': zhCN as unknown as FormatLocaleDefinition,
25+
'es-ES': esES as unknown as FormatLocaleDefinition,
26+
'es-MX': esMX as unknown as FormatLocaleDefinition,
27+
'de-DE': deDE as unknown as FormatLocaleDefinition,
28+
'ja-JP': jaJP as unknown as FormatLocaleDefinition,
29+
'fr-FR': frFR as unknown as FormatLocaleDefinition,
30+
'pt-BR': ptBR as unknown as FormatLocaleDefinition,
31+
'ko-KR': koKR as unknown as FormatLocaleDefinition,
32+
'it-IT': itIT as unknown as FormatLocaleDefinition,
33+
'nl-NL': nlNL as unknown as FormatLocaleDefinition,
34+
'ru-RU': ruRU as unknown as FormatLocaleDefinition,
35+
};
36+
37+
const currencySymbols: Record<string, string> = {
38+
USD: '$',
39+
EUR: '€',
40+
GBP: '£',
41+
JPY: '¥',
42+
CNY: '¥',
43+
KRW: '₩',
44+
INR: '₹',
45+
RUB: '₽',
46+
};
47+
48+
function getCurrencySymbol(locale: string | undefined, currencyCode: string): [string, string] {
49+
try {
50+
const cf = new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode });
51+
const currencyParts = cf.formatToParts(1);
52+
const currencySymbol = currencyParts.find((p) => p.type === 'currency')?.value ?? currencyCode;
53+
const firstMeaningfulType = currencyParts.find((p) => !['literal', 'nan'].includes(p.type))?.type;
54+
const symbolIsPrefix = firstMeaningfulType === 'currency';
55+
56+
return symbolIsPrefix ? [currencySymbol, ''] : ['', currencySymbol];
57+
} catch {
58+
const symbol = currencySymbols[currencyCode] ?? currencyCode;
59+
return [symbol, ''];
60+
}
61+
}
62+
63+
function deriveGrouping(locale: string): number[] {
64+
// en-US → "1,234,567,890" → sizes [1,3,3,3] → [3]
65+
// en-IN → "1,23,45,67,890" → sizes [1,2,2,2,3] → [3,2]
66+
const sizes = new Intl.NumberFormat(locale).formatToParts(1234567890)
67+
.filter((p) => p.type === 'integer')
68+
.map((p) => p.value.length);
69+
70+
if (sizes.length <= 1) {
71+
return [3];
72+
}
73+
74+
// d3 repeats the last array element for all remaining groups,
75+
// so we only need the two rightmost (least-significant) group sizes.
76+
const first = sizes[sizes.length - 1];
77+
const second = sizes[sizes.length - 2];
78+
79+
return first === second ? [first] : [first, second];
80+
}
81+
82+
function getD3NumericLocaleFromIntl(locale: string, currencyCode = 'USD'): FormatLocaleDefinition {
83+
const nf = new Intl.NumberFormat(locale);
84+
const numParts = nf.formatToParts(1234567.89);
85+
const find = (type: string) => numParts.find((p) => p.type === type)?.value ?? '';
86+
87+
return {
88+
decimal: find('decimal') || '.',
89+
thousands: find('group') || ',',
90+
grouping: deriveGrouping(locale),
91+
currency: getCurrencySymbol(locale, currencyCode),
92+
};
93+
}
94+
95+
const localeCache: Record<string, FormatLocaleObject> = Object.create(null);
96+
97+
export function getD3NumericLocale(locale: string, currencyCode = 'USD'): FormatLocaleObject {
98+
const key = `${locale}:${currencyCode}`;
99+
if (localeCache[key]) {
100+
return localeCache[key];
101+
}
102+
103+
let definition: FormatLocaleDefinition;
104+
105+
if (formatD3NumericLocale[locale]) {
106+
definition = { ...formatD3NumericLocale[locale], currency: getCurrencySymbol(locale, currencyCode) };
107+
} else {
108+
try {
109+
definition = getD3NumericLocaleFromIntl(locale, currencyCode);
110+
} catch (e: unknown) {
111+
console.warn('Failed to generate d3 local via Intl, failing back to en-US', e);
112+
113+
definition = formatD3NumericLocale['en-US'];
114+
}
115+
}
116+
117+
localeCache[key] = formatLocale(definition);
118+
return localeCache[key];
119+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { format as d3Format } from 'd3-format';
2+
import { timeFormat } from 'd3-time-format';
3+
import { getD3NumericLocale } from './format-d3-numeric-locale';
4+
5+
import type { DimensionFormat, MeasureFormat, TCubeMemberType } from './types';
6+
7+
// Default d3-format specifiers — aligned with the named _2 formats
8+
// (number_2, currency_2, percent_2) in named-numeric-formats.ts
9+
const DEFAULT_NUMBER_FORMAT = ',.2f';
10+
const DEFAULT_CURRENCY_FORMAT = '$,.2f';
11+
const DEFAULT_PERCENT_FORMAT = '.2%';
12+
13+
function detectLocale() {
14+
try {
15+
return new Intl.NumberFormat().resolvedOptions().locale;
16+
} catch (e) {
17+
console.warn('Failed to detect locale', e);
18+
19+
return 'en-US';
20+
}
21+
}
22+
23+
const currentLocale = detectLocale();
24+
25+
const DEFAULT_DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S';
26+
const DEFAULT_DATE_FORMAT = '%Y-%m-%d';
27+
const DEFAULT_DATE_MONTH_FORMAT = '%Y-%m';
28+
const DEFAULT_DATE_QUARTER_FORMAT = '%Y-Q%q';
29+
const DEFAULT_DATE_YEAR_FORMAT = '%Y';
30+
31+
function getTimeFormatByGrain(grain: string | undefined): string {
32+
switch (grain) {
33+
case 'day':
34+
case 'week':
35+
return DEFAULT_DATE_FORMAT;
36+
case 'month':
37+
return DEFAULT_DATE_MONTH_FORMAT;
38+
case 'quarter':
39+
return DEFAULT_DATE_QUARTER_FORMAT;
40+
case 'year':
41+
return DEFAULT_DATE_YEAR_FORMAT;
42+
case 'second':
43+
case 'minute':
44+
case 'hour':
45+
default:
46+
return DEFAULT_DATETIME_FORMAT;
47+
}
48+
}
49+
50+
function parseNumber(value: any): number {
51+
if (value === null || value === undefined) {
52+
return 0;
53+
}
54+
55+
return parseFloat(value);
56+
}
57+
58+
export type FormatValueMember = {
59+
type: TCubeMemberType;
60+
format?: DimensionFormat | MeasureFormat;
61+
/** ISO 4217 currency code (e.g. 'USD', 'EUR'). Used when format is 'currency'. */
62+
currency?: string;
63+
/** Time dimension granularity (e.g. 'day', 'month', 'year'). Used for time formatting when no explicit format is set. */
64+
granularity?: string;
65+
};
66+
67+
export type FormatValueOptions = FormatValueMember & {
68+
/** Locale tag (e.g. 'en-US', 'de-DE', 'nl-NL'). Defaults to the runtime's locale via Intl.NumberFormat. */
69+
locale?: string,
70+
/** String to return for null/undefined values. Defaults to '∅'. */
71+
emptyPlaceholder?: string;
72+
};
73+
74+
export function formatValue(
75+
value: any,
76+
{ type, format, currency = 'USD', granularity, locale = currentLocale, emptyPlaceholder = '∅' }: FormatValueOptions
77+
): string {
78+
if (value === null || value === undefined) {
79+
return emptyPlaceholder;
80+
}
81+
82+
if (format && typeof format === 'object') {
83+
if (format.type === 'custom-numeric') {
84+
return d3Format(format.value)(parseNumber(value));
85+
}
86+
87+
if (format.type === 'custom-time') {
88+
const date = new Date(value);
89+
return Number.isNaN(date.getTime()) ? 'Invalid date' : timeFormat(format.value)(date);
90+
}
91+
92+
// { type: 'link', label: string } — return value as string
93+
return String(value);
94+
}
95+
96+
if (typeof format === 'string') {
97+
switch (format) {
98+
case 'currency':
99+
return getD3NumericLocale(locale, currency).format(DEFAULT_CURRENCY_FORMAT)(parseNumber(value));
100+
case 'percent':
101+
return getD3NumericLocale(locale).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value));
102+
case 'number':
103+
return getD3NumericLocale(locale).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
104+
case 'imageUrl':
105+
case 'id':
106+
case 'link':
107+
default:
108+
return String(value);
109+
}
110+
}
111+
112+
// No explicit format — infer from type
113+
if (type === 'time') {
114+
const date = new Date(value);
115+
if (Number.isNaN(date.getTime())) return 'Invalid date';
116+
117+
return timeFormat(getTimeFormatByGrain(granularity))(date);
118+
}
119+
120+
if (type === 'number') {
121+
return getD3NumericLocale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
122+
}
123+
124+
return String(value);
125+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,3 +910,5 @@ export * from './HttpTransport';
910910
export * from './utils';
911911
export * from './time';
912912
export * from './types';
913+
// We don't export it for now, because size of builds for cjs/umd users will be affected
914+
// export * from './format';
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
2+
3+
describe('formatValue without Intl', () => {
4+
const originalIntl = globalThis.Intl;
5+
6+
beforeAll(() => {
7+
vi.resetModules();
8+
9+
// @ts-expect-error — intentionally removing Intl to simulate environments where it is unavailable
10+
delete globalThis.Intl;
11+
});
12+
13+
afterAll(() => {
14+
globalThis.Intl = originalIntl;
15+
});
16+
17+
it('detectLocale falls back to en-US and formatting works', async () => {
18+
const { formatValue } = await import('../src/format');
19+
20+
// number type uses the detected locale (should be en-US fallback)
21+
expect(formatValue(1234.56, { type: 'number' })).toBe('1,234.56');
22+
});
23+
24+
it('currency formatting falls back to en-US locale definition', async () => {
25+
const { formatValue } = await import('../src/format');
26+
27+
expect(formatValue(1234.56, { type: 'number', format: 'currency' })).toBe('$1,234.56');
28+
});
29+
30+
it('percent formatting works without Intl', async () => {
31+
const { formatValue } = await import('../src/format');
32+
33+
expect(formatValue(0.1234, { type: 'number', format: 'percent' })).toBe('12.34%');
34+
});
35+
36+
it('time formatting works without Intl', async () => {
37+
const { formatValue } = await import('../src/format');
38+
39+
expect(formatValue('2024-03-15T00:00:00.000', { type: 'time', granularity: 'day' })).toBe('2024-03-15');
40+
});
41+
42+
it('null/undefined still return emptyPlaceholder', async () => {
43+
const { formatValue } = await import('../src/format');
44+
45+
expect(formatValue(null, { type: 'number' })).toBe('∅');
46+
expect(formatValue(undefined, { type: 'number' })).toBe('∅');
47+
});
48+
49+
// Known locale (de-DE) — pre-built d3 definition is used,
50+
// getCurrencySymbol falls back to the static currencySymbols map.
51+
it('known locale (de-DE) uses pre-built locale definition', async () => {
52+
const { formatValue } = await import('../src/format');
53+
54+
expect(formatValue(1234.56, { type: 'number', format: 'number', locale: 'de-DE' })).toBe('1.234,56');
55+
expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'EUR', locale: 'de-DE' })).toBe('€1.234,56');
56+
expect(formatValue(0.1234, { type: 'number', format: 'percent', locale: 'de-DE' })).toBe('12,34%');
57+
});
58+
59+
// Unknown locale (sv-SE) — getD3NumericLocaleFromIntl throws,
60+
// falls back entirely to en-US.
61+
it('unknown locale (sv-SE) falls back to en-US', async () => {
62+
const { formatValue } = await import('../src/format');
63+
64+
expect(formatValue(1234.56, { type: 'number', format: 'number', locale: 'sv-SE' })).toBe('1,234.56');
65+
expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'USD', locale: 'sv-SE' })).toBe('$1,234.56');
66+
});
67+
});

0 commit comments

Comments
 (0)