Skip to content

Commit b6e5886

Browse files
authored
feat(client-core): Format API - expose formatDateByGranularity, getFormat (#10704)
1 parent a54a79d commit b6e5886

3 files changed

Lines changed: 243 additions & 89 deletions

File tree

packages/cubejs-client-core/src/format-d3-numeric-locale.ts

Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,22 @@ import { formatLocale } from 'd3-format';
22

33
import type { FormatLocaleDefinition, FormatLocaleObject } from 'd3-format';
44

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-
195
// Pre-built d3 locale definitions for the most popular locales.
206
// 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,
7+
export const formatD3NumericLocale: Record<string, Omit<FormatLocaleDefinition, 'currency'>> = {
8+
'en-US': { decimal: '.', thousands: ',', grouping: [3] },
9+
'en-GB': { decimal: '.', thousands: ',', grouping: [3] },
10+
'zh-CN': { decimal: '.', thousands: ',', grouping: [3] },
11+
'es-ES': { decimal: ',', thousands: '.', grouping: [3] },
12+
'es-MX': { decimal: '.', thousands: ',', grouping: [3] },
13+
'de-DE': { decimal: ',', thousands: '.', grouping: [3] },
14+
'ja-JP': { decimal: '.', thousands: ',', grouping: [3] },
15+
'fr-FR': { decimal: ',', thousands: '\u00a0', grouping: [3], percent: '\u202f%' },
16+
'pt-BR': { decimal: ',', thousands: '.', grouping: [3] },
17+
'ko-KR': { decimal: '.', thousands: ',', grouping: [3] },
18+
'it-IT': { decimal: ',', thousands: '.', grouping: [3] },
19+
'nl-NL': { decimal: ',', thousands: '.', grouping: [3] },
20+
'ru-RU': { decimal: ',', thousands: '\u00a0', grouping: [3] },
3521
};
3622

3723
const currencySymbols: Record<string, string> = {
@@ -45,7 +31,7 @@ const currencySymbols: Record<string, string> = {
4531
RUB: '₽',
4632
};
4733

48-
function getCurrencySymbol(locale: string | undefined, currencyCode: string): [string, string] {
34+
function getCurrencyOverride(locale: string | undefined, currencyCode: string): [string, string] {
4935
try {
5036
const cf = new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode });
5137
const currencyParts = cf.formatToParts(1);
@@ -88,7 +74,7 @@ function getD3NumericLocaleFromIntl(locale: string, currencyCode = 'USD'): Forma
8874
decimal: find('decimal') || '.',
8975
thousands: find('group') || ',',
9076
grouping: deriveGrouping(locale),
91-
currency: getCurrencySymbol(locale, currencyCode),
77+
currency: getCurrencyOverride(locale, currencyCode),
9278
};
9379
}
9480

@@ -103,14 +89,17 @@ export function getD3NumericLocale(locale: string, currencyCode = 'USD'): Format
10389
let definition: FormatLocaleDefinition;
10490

10591
if (formatD3NumericLocale[locale]) {
106-
definition = { ...formatD3NumericLocale[locale], currency: getCurrencySymbol(locale, currencyCode) };
92+
definition = { ...formatD3NumericLocale[locale], currency: getCurrencyOverride(locale, currencyCode) };
10793
} else {
10894
try {
10995
definition = getD3NumericLocaleFromIntl(locale, currencyCode);
11096
} catch (e: unknown) {
11197
console.warn('Failed to generate d3 local via Intl, failing back to en-US', e);
11298

113-
definition = formatD3NumericLocale['en-US'];
99+
definition = {
100+
...formatD3NumericLocale['en-US'],
101+
currency: getCurrencyOverride(locale, currencyCode)
102+
};
114103
}
115104
}
116105

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

Lines changed: 131 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -26,27 +26,57 @@ const currentLocale = detectLocale();
2626

2727
const DEFAULT_DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S';
2828
const DEFAULT_DATE_FORMAT = '%Y-%m-%d';
29-
const DEFAULT_DATE_MONTH_FORMAT = '%Y-%m';
29+
const DEFAULT_DATE_WEEK_FORMAT = '%Y-%m-%d W%V';
30+
const DEFAULT_DATE_MONTH_FORMAT = '%Y %b';
3031
const DEFAULT_DATE_QUARTER_FORMAT = '%Y-Q%q';
3132
const DEFAULT_DATE_YEAR_FORMAT = '%Y';
3233

33-
function getTimeFormatByGrain(grain: string | undefined): string {
34-
switch (grain) {
35-
case 'day':
36-
case 'week':
37-
return DEFAULT_DATE_FORMAT;
38-
case 'month':
39-
return DEFAULT_DATE_MONTH_FORMAT;
40-
case 'quarter':
41-
return DEFAULT_DATE_QUARTER_FORMAT;
42-
case 'year':
43-
return DEFAULT_DATE_YEAR_FORMAT;
44-
case 'second':
45-
case 'minute':
46-
case 'hour':
47-
default:
48-
return DEFAULT_DATETIME_FORMAT;
34+
function getFormatByGrain(grain?: string): string {
35+
// Grains that should show date and time (sub-day granularities)
36+
const dateTimeGrains = ['second', 'minute', 'hour'];
37+
38+
// Grains that should show date only (day and above granularities)
39+
const dateOnlyGrains = ['day', 'week', 'month', 'quarter', 'year'];
40+
41+
if (grain === 'day') {
42+
return DEFAULT_DATE_FORMAT;
43+
}
44+
45+
if (grain === 'week') {
46+
return DEFAULT_DATE_WEEK_FORMAT;
47+
}
48+
49+
if (grain === 'month') {
50+
return DEFAULT_DATE_MONTH_FORMAT;
51+
}
52+
53+
if (grain === 'quarter') {
54+
return DEFAULT_DATE_QUARTER_FORMAT;
55+
}
56+
57+
if (grain === 'year') {
58+
return DEFAULT_DATE_YEAR_FORMAT;
59+
}
60+
61+
if (!grain || dateTimeGrains.includes(grain)) {
62+
return DEFAULT_DATETIME_FORMAT;
63+
}
64+
65+
if (dateOnlyGrains.includes(grain)) {
66+
return DEFAULT_DATE_FORMAT;
4967
}
68+
69+
// Fallback to datetime for unknown grains
70+
return DEFAULT_DATETIME_FORMAT;
71+
}
72+
73+
export function formatDateByGranularity(value: Date | string | number, granularity?: string): string {
74+
const date = value instanceof Date ? value : new Date(value);
75+
if (Number.isNaN(date.getTime())) {
76+
return 'Invalid date';
77+
}
78+
79+
return timeFormat(getFormatByGrain(granularity))(date);
5080
}
5181

5282
function parseNumber(value: any): number {
@@ -73,78 +103,125 @@ export type FormatValueOptions = FormatValueMember & {
73103
emptyPlaceholder?: string;
74104
};
75105

76-
export function formatValue(
77-
value: any,
78-
{ type, format, currency = 'USD', granularity, locale = currentLocale, emptyPlaceholder = '∅' }: FormatValueOptions
79-
): string {
80-
if (value === null || value === undefined) {
81-
return emptyPlaceholder;
106+
export type GetFormatOptions = {
107+
locale?: string;
108+
};
109+
110+
export type GetFormatResult = {
111+
formatString: string | null;
112+
formatFunc: (value: any) => string;
113+
};
114+
115+
function formatBoolean(value: any): string {
116+
if (typeof value === 'boolean') {
117+
return value.toString();
82118
}
83119

84-
if (type === 'boolean') {
85-
if (typeof value === 'boolean') {
86-
return value.toString();
87-
}
120+
if (typeof value === 'number') {
121+
return Boolean(value).toString();
122+
}
88123

89-
if (typeof value === 'number') {
90-
return Boolean(value).toString();
91-
}
124+
// Some SQL drivers return booleans as '0'/'1' or 'true'/'false' strings, It's incorrect behaivour in Cube,
125+
// but let's format it as boolean for backward compatibility.
126+
if (value === '0' || value === 'false') {
127+
return 'false';
128+
}
92129

93-
// Some SQL drivers return booleans as '0'/'1' or 'true'/'false' strings, It's incorrect behaivour in Cube,
94-
// but let's format it as boolean for backward compatibility.
95-
if (value === '0' || value === 'false') {
96-
return 'false';
97-
}
130+
if (value === '1' || value === 'true') {
131+
return 'true';
132+
}
98133

99-
if (value === '1' || value === 'true') {
100-
return 'true';
101-
}
134+
return String(value);
135+
}
136+
137+
export function getFormat(
138+
member: FormatValueMember,
139+
{ locale = currentLocale }: GetFormatOptions = {}
140+
): GetFormatResult {
141+
const { type, format, currency = 'USD', granularity } = member;
102142

103-
return String(value);
143+
if (type === 'boolean') {
144+
return { formatString: null, formatFunc: formatBoolean };
104145
}
105146

106147
if (format && typeof format === 'object') {
107148
if (format.type === 'custom-numeric') {
108-
return d3Format(format.value)(parseNumber(value));
149+
return {
150+
formatString: format.value,
151+
formatFunc: (value) => d3Format(format.value)(parseNumber(value)),
152+
};
109153
}
110154

111155
if (format.type === 'custom-time') {
112-
const date = new Date(value);
113-
return Number.isNaN(date.getTime()) ? 'Invalid date' : timeFormat(format.value)(date);
156+
return {
157+
formatString: format.value,
158+
formatFunc: (value) => {
159+
const date = new Date(value);
160+
return Number.isNaN(date.getTime()) ? 'Invalid date' : timeFormat(format.value)(date);
161+
},
162+
};
114163
}
115164

116165
// { type: 'link', label: string } — return value as string
117-
return String(value);
166+
return { formatString: null, formatFunc: (value) => String(value) };
118167
}
119168

120169
if (typeof format === 'string') {
121170
switch (format) {
122171
case 'currency':
123-
return getD3NumericLocale(locale, currency).format(DEFAULT_CURRENCY_FORMAT)(parseNumber(value));
172+
return {
173+
formatString: DEFAULT_CURRENCY_FORMAT,
174+
formatFunc: (value) => getD3NumericLocale(locale, currency).format(DEFAULT_CURRENCY_FORMAT)(parseNumber(value)),
175+
};
124176
case 'percent':
125-
return getD3NumericLocale(locale).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value));
177+
return {
178+
formatString: DEFAULT_PERCENT_FORMAT,
179+
formatFunc: (value) => getD3NumericLocale(locale).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value)),
180+
};
126181
case 'number':
127-
return getD3NumericLocale(locale).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
182+
return {
183+
formatString: DEFAULT_NUMBER_FORMAT,
184+
formatFunc: (value) => getD3NumericLocale(locale).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value)),
185+
};
128186
case 'id':
129-
return d3Format(DEFAULT_ID_FORMAT)(parseNumber(value));
187+
return {
188+
formatString: DEFAULT_ID_FORMAT,
189+
formatFunc: (value) => d3Format(DEFAULT_ID_FORMAT)(parseNumber(value)),
190+
};
130191
case 'imageUrl':
131192
case 'link':
132193
default:
133-
return String(value);
194+
return { formatString: null, formatFunc: (value) => String(value) };
134195
}
135196
}
136197

137198
// No explicit format — infer from type
138199
if (type === 'time') {
139-
const date = new Date(value);
140-
if (Number.isNaN(date.getTime())) return 'Invalid date';
141-
142-
return timeFormat(getTimeFormatByGrain(granularity))(date);
200+
return {
201+
formatString: getFormatByGrain(granularity),
202+
formatFunc: (value) => formatDateByGranularity(value, granularity),
203+
};
143204
}
144205

145206
if (type === 'number') {
146-
return getD3NumericLocale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
207+
return {
208+
formatString: DEFAULT_NUMBER_FORMAT,
209+
formatFunc: (value) => getD3NumericLocale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value)),
210+
};
147211
}
148212

149-
return String(value);
213+
return { formatString: null, formatFunc: (value) => String(value) };
214+
}
215+
216+
export function formatValue(
217+
value: any,
218+
options: FormatValueOptions
219+
): string {
220+
const { emptyPlaceholder = '∅' } = options;
221+
222+
if (value === null || value === undefined) {
223+
return emptyPlaceholder;
224+
}
225+
226+
return getFormat(options, { locale: options.locale }).formatFunc(value);
150227
}

0 commit comments

Comments
 (0)