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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { ContextEvaluator } from './ContextEvaluator';
import type { JoinGraph } from './JoinGraph';
import type { ErrorReporter } from './ErrorReporter';
import { CompilerInterface } from './PrepareCompiler';
import { resolveNamedNumericFormat } from './named-numeric-formats';

export type CustomNumericFormat = { type: 'custom-numeric'; value: string };
export type DimensionCustomTimeFormat = { type: 'custom-time'; value: string };
Expand Down Expand Up @@ -413,6 +414,13 @@ export class CubeToMetaTransformer implements CompilerInterface {
return format;
}

// Resolve named numeric formats (abbr, accounting, number_X, percent_X, etc.)
const resolved = resolveNamedNumericFormat(format);
if (resolved) {
return { type: 'custom-numeric', value: resolved };
}

// Existing standard formats stay as-is (breaking change to convert these)
const standardFormats = ['imageUrl', 'currency', 'percent', 'number', 'id'];
if (standardFormats.includes(format)) {
return format;
Expand All @@ -423,7 +431,7 @@ export class CubeToMetaTransformer implements CompilerInterface {
return { type: 'custom-time', value: format };
}

// Custom numeric format for number dimensions
// Custom numeric format for number dimensions (raw d3-format specifier)
if (type === 'number') {
return { type: 'custom-numeric', value: format };
}
Expand All @@ -436,12 +444,19 @@ export class CubeToMetaTransformer implements CompilerInterface {
return undefined;
}

// Resolve named numeric formats (abbr, accounting, number_X, percent_X, etc.)
const resolved = resolveNamedNumericFormat(format);
if (resolved) {
return { type: 'custom-numeric', value: resolved };
}

// Existing standard formats stay as-is (breaking change to convert these)
const standardFormats = ['percent', 'currency', 'number'];
if (standardFormats.includes(format)) {
return format;
}

// Custom numeric format
// Custom numeric format (raw d3-format specifier)
return { type: 'custom-numeric', value: format };
}
}
20 changes: 17 additions & 3 deletions packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import cronParser from 'cron-parser';
import { CubeSymbols, CubeDefinition, ToString } from './CubeSymbols';
import type { ErrorReporter } from './ErrorReporter';
import { CompilerInterface } from './PrepareCompiler';
import { NAMED_NUMERIC_FORMATS } from './named-numeric-formats';

/* *****************************
* ATTENTION:
Expand Down Expand Up @@ -112,13 +113,14 @@ const GranularityInterval = Joi.string().pattern(/^\d+\s+(second|minute|hour|day
// Do not allow negative intervals for granularities, while offsets could be negative
const GranularityOffset = Joi.string().pattern(/^-?(\d+\s+)(second|minute|hour|day|week|month|quarter|year)s?(\s-?\d+\s+(second|minute|hour|day|week|month|quarter|year)s?){0,7}$/, 'granularity offset');

const formatSchema = Joi.alternatives([
const formatAlternatives = [
Joi.string().valid('imageUrl', 'link', 'currency', 'percent', 'number', 'id'),
Joi.object().keys({
type: Joi.string().valid('link'),
label: Joi.string().required()
})
]);
];
const formatSchema = Joi.alternatives(formatAlternatives);

// POSIX strftime specification (IEEE Std 1003.1 / POSIX.1) with d3-time-format extensions
// See: https://pubs.opengroup.org/onlinepubs/009695399/functions/strptime.html
Expand Down Expand Up @@ -249,13 +251,25 @@ const customNumericFormatSchema = Joi.string().custom((value, helper) => {
return value;
});

const namedNumericFormatSchema = Joi.string().custom((value, helper) => {
if (value in NAMED_NUMERIC_FORMATS) {
return value;
}

return helper.message({
custom: `"${value}" is not a valid named numeric format. Valid named formats: number, percent, currency, id, abbr, accounting (with optional _N decimal suffix, e.g. percent_3)`
});
});

const measureFormatSchema = Joi.alternatives([
Joi.string().valid('percent', 'currency', 'number'),
namedNumericFormatSchema,
customNumericFormatSchema
]);

const dimensionNumericFormatSchema = Joi.alternatives([
formatSchema,
...formatAlternatives,
namedNumericFormatSchema,
customNumericFormatSchema
]);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Predefined named numeric formats and their d3-format specifiers.
//
// "number", "percent", "currency", and "id" (without _X suffix) are already handled
// as separate format types in the existing API contract. Converting them to named
// formats here would be a breaking change. Only the _X suffixed variants are named.
export const NAMED_NUMERIC_FORMATS: Record<string, string> = {
// number: grouped fixed-point
number_0: ',.0f',
number_1: ',.1f',
number_2: ',.2f',
number_3: ',.3f',
number_4: ',.4f',
number_5: ',.5f',
number_6: ',.6f',

// percent: .X%
percent_0: '.0%',
percent_1: '.1%',
percent_2: '.2%',
percent_3: '.3%',
percent_4: '.4%',
percent_5: '.5%',
percent_6: '.6%',

// currency: $,.Xf
currency_0: '$,.0f',
currency_1: '$,.1f',
currency_2: '$,.2f',
currency_3: '$,.3f',
currency_4: '$,.4f',
currency_5: '$,.5f',
currency_6: '$,.6f',

// decimal (Looker compat, same as number): ,.Xf
// Alias to decimal_2
decimal: ',.2f',
decimal_0: ',.0f',
decimal_1: ',.1f',
decimal_2: ',.2f',
decimal_3: ',.3f',
decimal_4: ',.4f',
decimal_5: ',.5f',
decimal_6: ',.6f',

// abbr (SI prefix): .Xs
// Alias to abbr_2
abbr: '.2s',
abbr_0: '.0s',
abbr_1: '.1s',
abbr_2: '.2s',
abbr_3: '.3s',
abbr_4: '.4s',
abbr_5: '.5s',
abbr_6: '.6s',

// accounting (negative in parens): (,.Xf
// Alias to accounting_2
accounting: '(,.2f',
accounting_0: '(,.0f',
accounting_1: '(,.1f',
accounting_2: '(,.2f',
accounting_3: '(,.3f',
accounting_4: '(,.4f',
accounting_5: '(,.5f',
accounting_6: '(,.6f',
};

export function resolveNamedNumericFormat(value: string): string | undefined {
return NAMED_NUMERIC_FORMATS[value];
}
Original file line number Diff line number Diff line change
Expand Up @@ -1601,6 +1601,67 @@ describe('Cube Validation', () => {
});
});

describe('Named numeric formats', () => {
it('measures with valid named formats - correct', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
measures: {
num3: { sql: () => 'amount', type: 'sum', format: 'number_3' },
pct1: { sql: () => 'ratio', type: 'avg', format: 'percent_1' },
cur4: { sql: () => 'revenue', type: 'sum', format: 'currency_4' },
dec5: { sql: () => 'amount', type: 'sum', format: 'decimal_5' },
ab: { sql: () => 'bytes', type: 'sum', format: 'abbr' },
ab2: { sql: () => 'bytes', type: 'sum', format: 'abbr_2' },
acc: { sql: () => 'amount', type: 'sum', format: 'accounting' },
acc0: { sql: () => 'amount', type: 'sum', format: 'accounting_0' },
},
fileName: 'fileName',
};

const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
expect(validationResult.error).toBeFalsy();
});

it('number dimensions with valid named formats - correct', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
dimensions: {
price: { sql: () => 'price', type: 'number', format: 'number_2' },
discount: { sql: () => 'discount', type: 'number', format: 'percent_0' },
total: { sql: () => 'total', type: 'number', format: 'currency_1' },
pop: { sql: () => 'pop', type: 'number', format: 'abbr_3' },
ab: { sql: () => 'ab', type: 'number', format: 'abbr' },
bal: { sql: () => 'bal', type: 'number', format: 'accounting' },
dec: { sql: () => 'dec', type: 'number', format: 'decimal_4' },
dec2: { sql: () => 'dec2', type: 'number', format: 'decimal' },
},
fileName: 'fileName',
};

const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
expect(validationResult.error).toBeFalsy();
});

it('string dimension with named numeric format - error', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
dimensions: {
status: { sql: () => 'status', type: 'string', format: 'abbr_2' },
},
fileName: 'fileName',
};

const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
expect(validationResult.error).toBeTruthy();
});
});

describe('Custom numeric format for number dimensions (d3-format)', () => {
it('number dimensions with valid d3-format and standard formats - correct', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
Expand Down
92 changes: 92 additions & 0 deletions packages/cubejs-schema-compiler/test/unit/yaml-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1022,4 +1022,96 @@ cubes:
}
});
});

describe('Named numeric formats', () => {
it('measure with named format in YAML', async () => {
const { compiler, metaTransformer } = prepareYamlCompiler(`
cubes:
- name: Orders
sql: "select * from tbl"
dimensions:
- name: id
sql: id
type: number
primary_key: true
measures:
- name: total_amount
sql: amount
type: sum
format: accounting_2
- name: bytes
sql: bytes
type: sum
format: abbr_3
`);

await compiler.compile();

const { measures } = metaTransformer.cubes[0].config;
const totalAmount = measures.find((m) => m.name === 'Orders.total_amount');
expect(totalAmount).toBeDefined();
expect(totalAmount!.format).toEqual({ type: 'custom-numeric', value: '(,.2f' });

const bytes = measures.find((m) => m.name === 'Orders.bytes');
expect(bytes).toBeDefined();
expect(bytes!.format).toEqual({ type: 'custom-numeric', value: '.3s' });
});

it('number dimension with named format in YAML', async () => {
const { compiler, metaTransformer } = prepareYamlCompiler(`
cubes:
- name: Orders
sql: "select * from tbl"
dimensions:
- name: id
sql: id
type: number
primary_key: true
- name: price
sql: price
type: number
format: currency_1
- name: population
sql: population
type: number
format: abbr
`);

await compiler.compile();

const { dimensions } = metaTransformer.cubes[0].config;
const price = dimensions.find((d) => d.name === 'Orders.price');
expect(price).toBeDefined();
expect(price!.format).toEqual({ type: 'custom-numeric', value: '$,.1f' });

const population = dimensions.find((d) => d.name === 'Orders.population');
expect(population).toBeDefined();
expect(population!.format).toEqual({ type: 'custom-numeric', value: '.2s' });
});

it('invalid named format in YAML - error', async () => {
const { compiler } = prepareYamlCompiler(`
cubes:
- name: Orders
sql: "select * from tbl"
dimensions:
- name: id
sql: id
type: number
primary_key: true
measures:
- name: total_amount
sql: amount
type: sum
format: unknown_format
`);

try {
await compiler.compile();
throw new Error('compile must return an error');
} catch (e: any) {
expect(e.message).toContain('format');
}
});
});
});
Loading