Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/cubejs-client-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"dependencies": {
"core-js": "^3.6.5",
"cross-fetch": "^3.0.2",
"d3-format": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.10.4",
"ramda": "^0.27.2",
"url-search-params-polyfill": "^7.0.0",
Expand All @@ -41,6 +43,8 @@
"license": "MIT",
"devDependencies": {
"@cubejs-backend/linter": "1.6.32",
"@types/d3-format": "^3",
"@types/d3-time-format": "^4",
"@types/moment-range": "^4.0.0",
"@types/ramda": "^0.27.34",
"@vitest/coverage-v8": "^4",
Expand Down
119 changes: 119 additions & 0 deletions packages/cubejs-client-core/src/format-d3-numeric-locale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { formatLocale } from 'd3-format';

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

import enUS from 'd3-format/locale/en-US.json';
import enGB from 'd3-format/locale/en-GB.json';
import zhCN from 'd3-format/locale/zh-CN.json';
import esES from 'd3-format/locale/es-ES.json';
import esMX from 'd3-format/locale/es-MX.json';
import deDE from 'd3-format/locale/de-DE.json';
import jaJP from 'd3-format/locale/ja-JP.json';
import frFR from 'd3-format/locale/fr-FR.json';
import ptBR from 'd3-format/locale/pt-BR.json';
import koKR from 'd3-format/locale/ko-KR.json';
import itIT from 'd3-format/locale/it-IT.json';
import nlNL from 'd3-format/locale/nl-NL.json';
import ruRU from 'd3-format/locale/ru-RU.json';

// Pre-built d3 locale definitions for the most popular locales.
// Used as a fallback when Intl is unavailable (e.g. some edge runtimes).
export const formatD3NumericLocale: Record<string, FormatLocaleDefinition> = {
'en-US': enUS as unknown as FormatLocaleDefinition,
'en-GB': enGB as unknown as FormatLocaleDefinition,
'zh-CN': zhCN as unknown as FormatLocaleDefinition,
'es-ES': esES as unknown as FormatLocaleDefinition,
'es-MX': esMX as unknown as FormatLocaleDefinition,
'de-DE': deDE as unknown as FormatLocaleDefinition,
'ja-JP': jaJP as unknown as FormatLocaleDefinition,
'fr-FR': frFR as unknown as FormatLocaleDefinition,
'pt-BR': ptBR as unknown as FormatLocaleDefinition,
'ko-KR': koKR as unknown as FormatLocaleDefinition,
'it-IT': itIT as unknown as FormatLocaleDefinition,
'nl-NL': nlNL as unknown as FormatLocaleDefinition,
'ru-RU': ruRU as unknown as FormatLocaleDefinition,
};

const currencySymbols: Record<string, string> = {
USD: '$',
EUR: '€',
GBP: '£',
JPY: '¥',
CNY: '¥',
KRW: '₩',
INR: '₹',
RUB: '₽',
};

function getCurrencySymbol(locale: string | undefined, currencyCode: string): [string, string] {
try {
const cf = new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode });
const currencyParts = cf.formatToParts(1);
const currencySymbol = currencyParts.find((p) => p.type === 'currency')?.value ?? currencyCode;
const firstMeaningfulType = currencyParts.find((p) => !['literal', 'nan'].includes(p.type))?.type;
const symbolIsPrefix = firstMeaningfulType === 'currency';

return symbolIsPrefix ? [currencySymbol, ''] : ['', currencySymbol];
} catch {
const symbol = currencySymbols[currencyCode] ?? currencyCode;
return [symbol, ''];
}
}

function deriveGrouping(locale: string): number[] {
// en-US → "1,234,567,890" → sizes [1,3,3,3] → [3]
// en-IN → "1,23,45,67,890" → sizes [1,2,2,2,3] → [3,2]
const sizes = new Intl.NumberFormat(locale).formatToParts(1234567890)
.filter((p) => p.type === 'integer')
.map((p) => p.value.length);

if (sizes.length <= 1) {
return [3];
}

// d3 repeats the last array element for all remaining groups,
// so we only need the two rightmost (least-significant) group sizes.
const first = sizes[sizes.length - 1];
const second = sizes[sizes.length - 2];

return first === second ? [first] : [first, second];
}

function getD3NumericLocaleFromIntl(locale: string, currencyCode = 'USD'): FormatLocaleDefinition {
const nf = new Intl.NumberFormat(locale);
const numParts = nf.formatToParts(1234567.89);
const find = (type: string) => numParts.find((p) => p.type === type)?.value ?? '';

return {
decimal: find('decimal') || '.',
thousands: find('group') || ',',
grouping: deriveGrouping(locale),
currency: getCurrencySymbol(locale, currencyCode),
};
}

const localeCache: Record<string, FormatLocaleObject> = Object.create(null);

export function getD3NumericLocale(locale: string, currencyCode = 'USD'): FormatLocaleObject {
const key = `${locale}:${currencyCode}`;
if (localeCache[key]) {
return localeCache[key];
}

let definition: FormatLocaleDefinition;

if (formatD3NumericLocale[locale]) {
definition = { ...formatD3NumericLocale[locale], currency: getCurrencySymbol(locale, currencyCode) };
} else {
try {
definition = getD3NumericLocaleFromIntl(locale, currencyCode);
} catch (e: unknown) {
console.warn('Failed to generate d3 local via Intl, failing back to en-US', e);

definition = formatD3NumericLocale['en-US'];
}
}

localeCache[key] = formatLocale(definition);
return localeCache[key];
}
125 changes: 125 additions & 0 deletions packages/cubejs-client-core/src/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { format as d3Format } from 'd3-format';
import { timeFormat } from 'd3-time-format';
import { getD3NumericLocale } from './format-d3-numeric-locale';

import type { DimensionFormat, MeasureFormat, TCubeMemberType } from './types';

// Default d3-format specifiers — aligned with the named _2 formats
// (number_2, currency_2, percent_2) in named-numeric-formats.ts
const DEFAULT_NUMBER_FORMAT = ',.2f';
const DEFAULT_CURRENCY_FORMAT = '$,.2f';
const DEFAULT_PERCENT_FORMAT = '.2%';

function detectLocale() {
try {
return new Intl.NumberFormat().resolvedOptions().locale;
} catch (e) {
console.warn('Failed to detect locale', e);

return 'en-US';
}
}

const currentLocale = detectLocale();

const DEFAULT_DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S';
const DEFAULT_DATE_FORMAT = '%Y-%m-%d';
const DEFAULT_DATE_MONTH_FORMAT = '%Y-%m';
const DEFAULT_DATE_QUARTER_FORMAT = '%Y-Q%q';
const DEFAULT_DATE_YEAR_FORMAT = '%Y';

function getTimeFormatByGrain(grain: string | undefined): string {
switch (grain) {
case 'day':
case 'week':
return DEFAULT_DATE_FORMAT;
case 'month':
return DEFAULT_DATE_MONTH_FORMAT;
case 'quarter':
return DEFAULT_DATE_QUARTER_FORMAT;
case 'year':
return DEFAULT_DATE_YEAR_FORMAT;
case 'second':
case 'minute':
case 'hour':
default:
return DEFAULT_DATETIME_FORMAT;
}
}

function parseNumber(value: any): number {
if (value === null || value === undefined) {
return 0;
}

return parseFloat(value);
}

export type FormatValueMember = {
type: TCubeMemberType;
format?: DimensionFormat | MeasureFormat;
/** ISO 4217 currency code (e.g. 'USD', 'EUR'). Used when format is 'currency'. */
currency?: string;
/** Time dimension granularity (e.g. 'day', 'month', 'year'). Used for time formatting when no explicit format is set. */
granularity?: string;
};

export type FormatValueOptions = FormatValueMember & {
/** Locale tag (e.g. 'en-US', 'de-DE', 'nl-NL'). Defaults to the runtime's locale via Intl.NumberFormat. */
locale?: string,
/** String to return for null/undefined values. Defaults to '∅'. */
emptyPlaceholder?: string;
};

export function formatValue(
value: any,
{ type, format, currency = 'USD', granularity, locale = currentLocale, emptyPlaceholder = '∅' }: FormatValueOptions
): string {
if (value === null || value === undefined) {
return emptyPlaceholder;
}

if (format && typeof format === 'object') {
if (format.type === 'custom-numeric') {
return d3Format(format.value)(parseNumber(value));
}

if (format.type === 'custom-time') {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? 'Invalid date' : timeFormat(format.value)(date);
}

// { type: 'link', label: string } — return value as string
return String(value);
}

if (typeof format === 'string') {
switch (format) {
case 'currency':
return getD3NumericLocale(locale, currency).format(DEFAULT_CURRENCY_FORMAT)(parseNumber(value));
case 'percent':
return getD3NumericLocale(locale).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value));
case 'number':
return getD3NumericLocale(locale).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
case 'imageUrl':
case 'id':
case 'link':
default:
return String(value);
}
}

// No explicit format — infer from type
if (type === 'time') {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return 'Invalid date';

return timeFormat(getTimeFormatByGrain(granularity))(date);
}

if (type === 'number') {
return getD3NumericLocale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
}

return String(value);
}
2 changes: 2 additions & 0 deletions packages/cubejs-client-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,3 +910,5 @@ export * from './HttpTransport';
export * from './utils';
export * from './time';
export * from './types';
// We don't export it for now, because size of builds for cjs/umd users will be affected
// export * from './format';
Comment on lines +913 to +914
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The export is commented out — meaning consumers can't actually use formatValue via the standard @cubejs-client/core import.

Is this intentional for this PR? If so, how are consumers expected to access this function? If this is meant to be usable in a follow-up (e.g., only from ESM builds), it might be worth adding a note in the PR description about the plan.

If tree-shaking is the concern, modern bundlers (Rollup, webpack, esbuild) will eliminate unused exports from ESM builds. The CJS/UMD concern is valid, but you could consider a separate entry point (e.g., @cubejs-client/core/format) instead of blocking the export entirely.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended for now, I will prepare separate entrypoints in different PR, It require testing

Comment on lines +913 to +914
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The export is still commented out — consumers can't access formatValue via the standard @cubejs-client/core import.

Is this intentional for this PR? If the concern is bundle size for CJS/UMD users, a separate entry point (e.g., @cubejs-client/core/format) could expose it without affecting existing builds. Alternatively, modern bundlers tree-shake unused ESM exports effectively.

If this is meant to land without the export, it might be worth noting in the PR description that this is a foundational PR with a follow-up planned to wire the export.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intended for now, I will prepare separate entrypoints in different PR, It require testing

67 changes: 67 additions & 0 deletions packages/cubejs-client-core/test/format-no-intl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';

describe('formatValue without Intl', () => {
const originalIntl = globalThis.Intl;

beforeAll(() => {
vi.resetModules();

// @ts-expect-error — intentionally removing Intl to simulate environments where it is unavailable
delete globalThis.Intl;
});

afterAll(() => {
globalThis.Intl = originalIntl;
});

it('detectLocale falls back to en-US and formatting works', async () => {
const { formatValue } = await import('../src/format');

// number type uses the detected locale (should be en-US fallback)
expect(formatValue(1234.56, { type: 'number' })).toBe('1,234.56');
});

it('currency formatting falls back to en-US locale definition', async () => {
const { formatValue } = await import('../src/format');

expect(formatValue(1234.56, { type: 'number', format: 'currency' })).toBe('$1,234.56');
});

it('percent formatting works without Intl', async () => {
const { formatValue } = await import('../src/format');

expect(formatValue(0.1234, { type: 'number', format: 'percent' })).toBe('12.34%');
});

it('time formatting works without Intl', async () => {
const { formatValue } = await import('../src/format');

expect(formatValue('2024-03-15T00:00:00.000', { type: 'time', granularity: 'day' })).toBe('2024-03-15');
});

it('null/undefined still return emptyPlaceholder', async () => {
const { formatValue } = await import('../src/format');

expect(formatValue(null, { type: 'number' })).toBe('∅');
expect(formatValue(undefined, { type: 'number' })).toBe('∅');
});

// Known locale (de-DE) — pre-built d3 definition is used,
// getCurrencySymbol falls back to the static currencySymbols map.
it('known locale (de-DE) uses pre-built locale definition', async () => {
const { formatValue } = await import('../src/format');

expect(formatValue(1234.56, { type: 'number', format: 'number', locale: 'de-DE' })).toBe('1.234,56');
expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'EUR', locale: 'de-DE' })).toBe('€1.234,56');
expect(formatValue(0.1234, { type: 'number', format: 'percent', locale: 'de-DE' })).toBe('12,34%');
});

// Unknown locale (sv-SE) — getD3NumericLocaleFromIntl throws,
// falls back entirely to en-US.
it('unknown locale (sv-SE) falls back to en-US', async () => {
const { formatValue } = await import('../src/format');

expect(formatValue(1234.56, { type: 'number', format: 'number', locale: 'sv-SE' })).toBe('1,234.56');
expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'USD', locale: 'sv-SE' })).toBe('$1,234.56');
});
});
Loading
Loading