Skip to content

feat(client-core): Support format#10653

Open
ovr wants to merge 5 commits intomasterfrom
feat/client-core-formatting
Open

feat(client-core): Support format#10653
ovr wants to merge 5 commits intomasterfrom
feat/client-core-formatting

Conversation

@ovr
Copy link
Copy Markdown
Member

@ovr ovr commented Apr 8, 2026

No description provided.

ovr added 4 commits April 8, 2026 18:41
…cale option

Replace hardcoded en-US locale with Intl.NumberFormat-derived locale that
resolves decimal/thousands separators and currency symbol position from
the runtime. Add locale option to FormatValueOptions, cached per
locale:currency pair. Extract FormatValueMember type.
@ovr ovr requested a review from a team as a code owner April 8, 2026 16:43
@github-actions github-actions bot added client:core Issues relating to the JavaScript client SDK javascript Pull requests that update Javascript code labels Apr 8, 2026
@claude
Copy link
Copy Markdown

claude bot commented Apr 8, 2026

Claude finished @ovr's task in 2m 59s —— View job


PR Review: feat(client-core): Support format

  • Read and analyze all changed files
  • Review code quality and best practices
  • Review security considerations
  • Review performance
  • Review test coverage
  • Post comprehensive review

Summary

This PR adds a formatValue utility to @cubejs-client/core that formats numbers, currencies, percentages, and time values using d3-format/d3-time-format, with locale support derived from the Intl API. The implementation is clean and well-structured overall. 6 inline comments posted on specific issues.

What's Good

  • Smart locale derivation: Using Intl.NumberFormat.formatToParts() to build d3 locale definitions is a solid approach — it leverages the platform's locale data without bundling CLDR.
  • Locale caching: The localeCache avoids recreating d3 locale objects on every call.
  • Clean type design: FormatValueOptions extends FormatValueMember cleanly, and the JSDoc comments on each field are helpful.
  • Good test coverage of the main formatting paths (currency, percent, number, custom-numeric, custom-time, passthrough, locale).

Key Issues

Priority Issue Location
🔴 High Export is commented out — consumers can't use formatValue. Needs clarity on the access strategy. index.ts:913-914
🟡 Medium parseNumber returns NaN for non-numeric strings — d3 will render literal "NaN" in the UI. Should handle gracefully. format.ts:70-76
🟡 Medium Hardcoded grouping: [3] ignores locales with non-uniform grouping (e.g., en-IN uses [3, 2]). format.ts:24-26
🟢 Low getCurrentLocale() allocates a new Intl.NumberFormat on every call — can be cached. format.ts:41-43
🟢 Low Currency code leaks into cache key for non-currency formats, causing duplicate cache entries. format.ts:119-122
🟢 Low Missing test edge cases: NaN input, invalid dates, custom emptyPlaceholder, very large numbers. format.test.ts:84

Security

No concerns — this is a pure formatting utility with no network calls, DOM manipulation, or dynamic code execution.

Verdict

The core logic is sound and well-tested for happy paths. The commented-out export is the most important question — it determines whether this feature is actually shippable in this PR or a foundation for a follow-up. The NaN handling should also be addressed before merge to avoid rendering "NaN" in production UIs.

Comment on lines +24 to +26
grouping: [3],
currency: symbolIsPrefix ? [currencySymbol, ''] : ['', currencySymbol],
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hardcoded grouping: [3] ignores locale-specific grouping patterns.

Some locales use non-uniform digit grouping — for example, en-IN (Indian English) groups as 12,34,567 (groups of 2 after the first group of 3). The Intl API exposes this, but it's not being extracted here.

You could derive it from the formatToParts output by inspecting the integer parts between group separators, e.g.:

Suggested change
grouping: [3],
currency: symbolIsPrefix ? [currencySymbol, ''] : ['', currencySymbol],
};
const integerParts = numParts.filter(p => p.type === 'integer').map(p => p.value.length);
// Intl gives groups left-to-right; d3 wants them right-to-left from the decimal
const grouping = integerParts.length > 1 ? integerParts.slice(1).reverse() : [3];
return {
decimal: find('decimal') || '.',
thousands: find('group') || ',',
grouping,
currency: symbolIsPrefix ? [currencySymbol, ''] : ['', currencySymbol],
};

Not critical since most Western locales use [3], but worth considering for correctness.

Comment on lines +70 to +76
function parseNumber(value: any): number {
if (value === null || value === undefined) {
return 0;
}

return parseFloat(value);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

parseNumber silently coerces non-numeric strings to NaN.

If someone passes value = "hello" with format: 'currency', parseFloat("hello") returns NaN, and d3-format will render "NaN". Consider returning the emptyPlaceholder or String(value) instead of formatting NaN:

function parseNumber(value: any): number | null {
  if (value === null || value === undefined) {
    return null;
  }
  const n = parseFloat(value);
  return Number.isNaN(n) ? null : n;
}

Then in the formatting paths, check for null and return emptyPlaceholder. This would prevent rendering literal "NaN" in the UI.

Comment on lines +119 to +122
case 'percent':
return getCurrentD3Locale(locale, currency).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value));
case 'number':
return getCurrentD3Locale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: The currency parameter is passed to getCurrentD3Locale even for non-currency formats (lines 120-122). It doesn't cause a bug, but it creates unnecessary cache entries when different currency codes are used — e.g., locale:EUR and locale:USD would cache two identical locale objects for format: 'number'.

Consider passing a fixed default for non-currency formats:

Suggested change
case 'percent':
return getCurrentD3Locale(locale, currency).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value));
case 'number':
return getCurrentD3Locale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
case 'percent':
return getCurrentD3Locale(locale).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value));
case 'number':
return getCurrentD3Locale(locale).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));

Comment on lines +913 to +914
// We don't export it for now, because size of builds for cjs/umd users will be affected
// export * from './format';
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.

@claude could you make it for me?

Copy link
Copy Markdown

@claude claude bot Apr 8, 2026

Choose a reason for hiding this comment

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

Claude finished @ovr's task in 2m 22s —— View job


All fixes implemented — push pending

  • Fix parseNumber to return null for NaN (prevents "NaN" in UI)
  • Fix invalid date handling to return emptyPlaceholder instead of garbled output
  • Fix currency code leaking into cache key for non-currency formats
  • Uncomment the formatValue export in index.ts
  • Add missing test cases (NaN input, invalid date, custom emptyPlaceholder, empty string)
  • Push to remote — commit 1dd2fe3 is ready locally but git push is blocked by the runner's permission mode. Please run git push origin feat/client-core-formatting to publish.

Changes in commit 1dd2fe3

src/format.ts

  • parseNumber returns number | nullnull when parseFloat yields NaN (e.g. "abc", "")
  • All numeric paths (currency, percent, number, custom-numeric, type-based number) return emptyPlaceholder on null instead of formatting literal "NaN"
  • Both time paths (custom-time, type-based time) validate new Date(value).getTime() and return emptyPlaceholder for invalid dates
  • percent / number / type-based-number paths no longer pass currency to getCurrentD3Locale, eliminating duplicate cache entries

src/index.ts

  • formatValue, FormatValueOptions, FormatValueMember are now exported from @cubejs-client/core

test/format.test.ts

  • non-numeric input returns emptyPlaceholder for numeric formats"abc" and "" with all numeric format paths
  • invalid date returns emptyPlaceholder for time formats — type-based and custom-time
  • custom emptyPlaceholder — verifies the option threads through to all fallback paths

expect(formatValue(true, { type: 'boolean' })).toBe('true');
expect(formatValue('', { type: 'string' })).toBe('');
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing test scenarios to consider:

  1. NaN / non-numeric input: formatValue("abc", { type: 'number', format: 'currency' }) — currently renders "NaN", which is likely undesirable
  2. Custom emptyPlaceholder: No test for formatValue(null, { type: 'number', emptyPlaceholder: 'N/A' })
  3. Invalid date input: formatValue("not-a-date", { type: 'time' })new Date("not-a-date") produces Invalid Date
  4. Very large numbers: e.g., Number.MAX_SAFE_INTEGER to verify formatting doesn't break
  5. Negative values with non-USD currency: e.g., formatValue(-1234.56, { type: 'number', format: 'currency', currency: 'EUR' })

Tests 1 and 3 are the most important since they exercise real-world edge cases where user data might be unexpected.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 8, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 57.84%. Comparing base (05ab975) to head (203919c).
⚠️ Report is 5 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #10653   +/-   ##
=======================================
  Coverage   57.84%   57.84%           
=======================================
  Files         215      215           
  Lines       16609    16609           
  Branches     3336     3336           
=======================================
  Hits         9607     9607           
  Misses       6514     6514           
  Partials      488      488           
Flag Coverage Δ
cube-backend 57.84% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ovr ovr closed this Apr 8, 2026
@ovr ovr reopened this Apr 8, 2026
@claude
Copy link
Copy Markdown

claude bot commented Apr 8, 2026

Claude finished @ovr's task in 2m 47s —— View job


PR Review: feat(client-core): Support format

  • Read and analyze all changed files
  • Review code quality and best practices
  • Review security considerations
  • Review performance
  • Review test coverage
  • Post comprehensive review

Summary

This PR adds a formatValue utility to @cubejs-client/core that formats numbers, currencies, percentages, and time values using d3-format/d3-time-format, with locale support derived from the Intl API. 5 inline comments posted on specific issues.

What's Improved Since Last Review

  • Locale detection is now cached (203919c): detectLocale() runs once at module load and stores the result in currentLocale, addressing the prior feedback about allocating Intl.NumberFormat on every call.

What's Good

  • Smart locale derivation: Using Intl.NumberFormat.formatToParts() to build d3 locale definitions leverages the platform's locale data without bundling CLDR — clean approach.
  • Locale caching: The localeCache with Object.create(null) avoids recreating d3 locale objects on every call.
  • Clean type design: FormatValueOptions extends FormatValueMember cleanly, and the JSDoc comments are helpful. The types align well with the existing Annotation and BaseCubeMember types in types.ts.
  • Good test coverage of the main formatting paths (currency, percent, number, custom-numeric, custom-time, passthrough, locale, grain-based time).
  • Graceful fallback for locale detection: The try/catch in detectLocale() with en-US fallback handles environments where Intl might not be available.

Remaining Issues

Priority Issue Location
🔴 High Export is commented out — consumers can't use formatValue. Needs clarity on the access strategy or a note in the PR description. index.ts:913-914
🟡 Medium parseNumber returns NaN for non-numeric strings — d3 will render literal "NaN" in the UI. Should return null and fall back to emptyPlaceholder. format.ts:78-84
🟡 Medium new Date() on invalid input produces Invalid DatetimeFormat silently outputs "NaN" placeholders for each directive. Should validate and fall back. format.ts:142
🟢 Low Currency code leaks into cache key for non-currency formats, causing duplicate cache entries for percent and number formats. format.ts:128-130
🟢 Low Missing test edge cases: NaN input, invalid dates, custom emptyPlaceholder, empty string with numeric format. format.test.ts:84

Security

No concerns — this is a pure formatting utility with no network calls, DOM manipulation, or dynamic code execution.

Verdict

The core logic is sound and well-tested for happy paths. The commented-out export is the most important question — it determines whether this feature is actually shippable or a foundation for a follow-up. The NaN/invalid date handling should be addressed before merge to avoid rendering "NaN" in production UIs — these are the most likely edge cases users will hit with real-world data.

Comment on lines +78 to +84
function parseNumber(value: any): number {
if (value === null || value === undefined) {
return 0;
}

return parseFloat(value);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

parseNumber silently returns NaN for non-numeric strings, which d3-format will render as literal "NaN" in the UI.

For example, formatValue("hello", { type: 'number', format: 'currency' }) will output "NaN". This is likely undesirable in a user-facing formatting utility. Consider returning null and falling back to emptyPlaceholder:

Suggested change
function parseNumber(value: any): number {
if (value === null || value === undefined) {
return 0;
}
return parseFloat(value);
}
function parseNumber(value: any): number | null {
if (value === null || value === undefined) {
return null;
}
const n = parseFloat(value);
return Number.isNaN(n) ? null : n;
}

Then in formatValue, check for null before formatting:

case 'currency': {
  const n = parseNumber(value);
  return n === null ? emptyPlaceholder : getCurrentD3Locale(locale, currency).format(DEFAULT_CURRENCY_FORMAT)(n);
}

This applies to all numeric formatting paths (currency, percent, number, custom-numeric, and the type-based number fallback).

Comment on lines +128 to +130
return getCurrentD3Locale(locale, currency).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value));
case 'number':
return getCurrentD3Locale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: currency parameter leaks into cache key for non-currency formats.

getCurrentD3Locale(locale, currency) is called for percent and number formats too, but the currency value only affects the currency symbol in the locale definition. This means getCurrentD3Locale('en-US', 'EUR').format(',.2~f') and getCurrentD3Locale('en-US', 'USD').format(',.2~f') produce identical results but occupy different cache slots.

Consider passing currency only for the 'currency' case:

Suggested change
return getCurrentD3Locale(locale, currency).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value));
case 'number':
return getCurrentD3Locale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));
case 'percent':
return getCurrentD3Locale(locale).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value));
case 'number':
return getCurrentD3Locale(locale).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value));

Same applies to the type-based number fallback on line 146.

// No explicit format — infer from type
if (type === 'time') {
const fmt = getTimeFormatByGrain(granularity);
return timeFormat(fmt)(new Date(value));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

new Date(value) on invalid input produces Invalid Date, and timeFormat will silently output "NaN" placeholders for each directive.

For example, formatValue("not-a-date", { type: 'time' }) renders something like "NaN-NaN-NaN NaN:NaN:NaN". Consider validating the date:

const d = new Date(value);
if (Number.isNaN(d.getTime())) {
  return emptyPlaceholder;
}
return timeFormat(fmt)(d);

This also applies to the custom-time path on line 116.

Comment on lines +913 to +914
// We don't export it for now, because size of builds for cjs/umd users will be affected
// export * from './format';
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.

expect(formatValue(true, { type: 'boolean' })).toBe('true');
expect(formatValue('', { type: 'string' })).toBe('');
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Consider adding tests for edge cases that real-world data will hit:

  1. Non-numeric input with numeric format: formatValue("abc", { type: 'number', format: 'currency' }) — currently renders "NaN"
  2. Invalid date input: formatValue("not-a-date", { type: 'time' }) — renders garbled "NaN-NaN-NaN..."
  3. Custom emptyPlaceholder: formatValue(null, { type: 'number', emptyPlaceholder: 'N/A' }) — should return 'N/A'
  4. Empty string with numeric format: formatValue("", { type: 'number', format: 'number' })parseFloat("") is NaN

Tests 1, 2, and 4 are the most important since they exercise cases where user data might be unexpected and the current behavior produces undesirable output.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client:core Issues relating to the JavaScript client SDK javascript Pull requests that update Javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants