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
17 changes: 16 additions & 1 deletion packages/cubejs-api-gateway/openspec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,26 @@ components:
required:
- label
- type
V1CubeMetaCustomTimeFormat:
type: "object"
description: Custom time format for time dimensions
properties:
type:
type: "string"
enum: ["custom-time"]
description: Type of the format (must be 'custom-time')
value:
type: "string"
description: "POSIX strftime format string (IEEE Std 1003.1 / POSIX.1) with d3-time-format extensions (e.g., '%Y-%m-%d', '%d/%m/%Y %H:%M:%S'). See https://pubs.opengroup.org/onlinepubs/009695399/functions/strptime.html and https://d3js.org/d3-time-format"
required:
- type
- value
V1CubeMetaFormat:
oneOf:
- $ref: "#/components/schemas/V1CubeMetaSimpleFormat"
- $ref: "#/components/schemas/V1CubeMetaLinkFormat"
description: Format of dimension - can be either a simple string format or an object with link configuration
- $ref: "#/components/schemas/V1CubeMetaCustomTimeFormat"
description: Format of dimension - can be a simple string format, a link configuration, or a custom time format
V1MetaResponse:
type: "object"
properties:
Expand Down
8 changes: 7 additions & 1 deletion packages/cubejs-client-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,17 @@ export type GranularityAnnotation = {
origin?: string;
};

export type DimensionCustomTimeFormat = { type: 'custom-time'; value: string };
export type DimensionLinkFormat = { type: 'link'; label: string };
export type DimensionFormat = 'percent' | 'currency' | 'number' | 'imageUrl' | 'id' | 'link'
| DimensionLinkFormat | DimensionCustomTimeFormat;

export type Annotation = {
title: string;
shortTitle: string;
type: string;
meta?: any;
format?: 'currency' | 'percent' | 'number';
format?: DimensionFormat;
drillMembers?: any[];
drillMembersGrouped?: any;
granularity?: GranularityAnnotation;
Expand Down Expand Up @@ -388,6 +393,7 @@ export type CubeTimeDimensionGranularity = {
export type BaseCubeDimension = BaseCubeMember & {
primaryKey?: boolean;
suggestFilterValues: boolean;
format?: DimensionFormat;
};

export type CubeTimeDimension = BaseCubeDimension &
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,18 @@ export type MeasureConfig = {
public: boolean;
};

export type DimensionCustomTimeFormat = { type: 'custom-time'; value: string };
export type DimensionLinkFormat = { type: 'link'; label?: string };
export type DimensionFormat = string | DimensionLinkFormat | DimensionCustomTimeFormat;

export type DimensionConfig = {
name: string;
title: string;
type: string;
description?: string;
shortTitle: string;
suggestFilterValues: boolean;
format?: string;
format?: DimensionFormat;
meta?: any;
isVisible: boolean;
public: boolean;
Expand Down Expand Up @@ -252,7 +256,7 @@ export class CubeToMetaTransformer implements CompilerInterface {
extendedDimDef.suggestFilterValues == null
? true
: extendedDimDef.suggestFilterValues,
format: extendedDimDef.format,
format: this.transformDimensionFormat(extendedDimDef),
meta: extendedDimDef.meta,
isVisible: dimensionVisibility,
public: dimensionVisibility,
Expand Down Expand Up @@ -390,4 +394,23 @@ export class CubeToMetaTransformer implements CompilerInterface {
private titleize(name: string): string {
return inflection.titleize(inflection.underscore(camelCase(name, { pascalCase: true })));
}

private transformDimensionFormat({ format, type }: ExtendedCubeSymbolDefinition): DimensionFormat | undefined {
if (!format || type !== 'time') {
return format;
}

if (typeof format === 'object') {
return format;
}

// I don't know why, but we allow to define these formats for time dimensions.
// TODO: Should we deprecate it?
const standardFormats = ['imageUrl', 'currency', 'percent', 'number', 'id'];
if (standardFormats.includes(format)) {
return format;
}

return { type: 'custom-time', value: format };
}
}
69 changes: 68 additions & 1 deletion packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,69 @@ const formatSchema = Joi.alternatives([
})
]);

// POSIX strftime specification (IEEE Std 1003.1 / POSIX.1) with d3-time-format extensions
// See: https://pubs.opengroup.org/onlinepubs/009695399/functions/strptime.html
// See: https://d3js.org/d3-time-format
const TIME_SPECIFIERS = new Set([
// POSIX standard specifiers
'a', 'A', 'b', 'B', 'c', 'd', 'H', 'I', 'j', 'm',
'M', 'n', 'p', 'S', 't', 'U', 'w', 'W', 'x', 'X',
'y', 'Y', 'Z', '%',
// d3-time-format extensions
'e', // space-padded day of month
'f', // microseconds
'g', // ISO 8601 year without century
'G', // ISO 8601 year with century
'L', // milliseconds
'q', // quarter
'Q', // milliseconds since UNIX epoch
's', // seconds since UNIX epoch
'u', // Monday-based weekday [1,7]
'V', // ISO 8601 week number
]);

const customTimeFormatSchema = Joi.string().custom((value, helper) => {
let hasSpecifier = false;
let i = 0;

while (i < value.length) {
if (value[i] === '%') {
if (i + 1 >= value.length) {
return helper.message({ custom: `Invalid time format "${value}". Incomplete specifier at end of string` });
}

const specifier = value[i + 1];

if (!TIME_SPECIFIERS.has(specifier)) {
return helper.message({ custom: `Invalid time format "${value}". Unknown specifier '%${specifier}'` });
}

// %% is an escape for literal %, not a date/time specifier
if (specifier !== '%') {
hasSpecifier = true;
}

i += 2;
} else {
// Any other character is treated as literal text
i++;
}
}

if (!hasSpecifier) {
return helper.message({
custom: `Invalid strptime format "${value}". Format must contain at least one strptime specifier (e.g., %Y, %m, %d)`
});
}

return value;
});

const timeFormatSchema = Joi.alternatives([
formatSchema,
customTimeFormatSchema
]);

const BaseDimensionWithoutSubQuery = {
aliases: Joi.array().items(Joi.string()),
type: Joi.any().valid('string', 'number', 'boolean', 'time', 'geo').required(),
Expand All @@ -131,7 +194,11 @@ const BaseDimensionWithoutSubQuery = {
description: Joi.string(),
suggestFilterValues: Joi.boolean().strict(),
enableSuggestions: Joi.boolean().strict(),
format: formatSchema,
format: Joi.when('type', {
is: 'time',
then: timeFormatSchema,
otherwise: formatSchema
}),
meta: Joi.any(),
values: Joi.when('type', {
is: 'switch',
Expand Down
192 changes: 192 additions & 0 deletions packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1292,4 +1292,196 @@ describe('Cube Validation', () => {
expect(result.error).toBeTruthy();
});
});

describe('Custom time format for time dimensions (strptime)', () => {
it('time dimension with valid strptime format - correct', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
dimensions: {
createdAt: {
sql: () => 'created_at',
type: 'time',
format: '%Y-%m-%d'
},
},
fileName: 'fileName',
};

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

it('time dimension with complex strptime format - correct', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
dimensions: {
createdAt: {
sql: () => 'created_at',
type: 'time',
format: '%d/%m/%Y %H:%M:%S'
},
},
fileName: 'fileName',
};

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

it('time dimension with literal text in format - correct', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
dimensions: {
createdAt: {
sql: () => 'created_at',
type: 'time',
format: '%Y Year %m Month'
},
},
fileName: 'fileName',
};

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

it('time dimension with escaped percent - correct', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
dimensions: {
createdAt: {
sql: () => 'created_at',
type: 'time',
format: '%Y-%m-%d %%'
},
},
fileName: 'fileName',
};

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

it('time dimension with standard format - correct', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
dimensions: {
createdAt: {
sql: () => 'created_at',
type: 'time',
format: 'id'
},
},
fileName: 'fileName',
};

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

it('time dimension with invalid format (no specifiers) - error', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
dimensions: {
createdAt: {
sql: () => 'created_at',
type: 'time',
format: 'invalid'
},
},
fileName: 'fileName',
};

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

it('time dimension with invalid specifier - error', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
dimensions: {
createdAt: {
sql: () => 'created_at',
type: 'time',
format: '%Y-%K-%d'
},
},
fileName: 'fileName',
};

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

it('time dimension with incomplete specifier at end - error', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
dimensions: {
createdAt: {
sql: () => 'created_at',
type: 'time',
format: '%Y-%m-%'
},
},
fileName: 'fileName',
};

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

it('time dimension with only escaped percent - error', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
dimensions: {
createdAt: {
sql: () => 'created_at',
type: 'time',
format: '%%'
},
},
fileName: 'fileName',
};

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

it('non-time dimension with strptime format string - error', async () => {
const cubeValidator = new CubeValidator(new CubeSymbols());
const cube = {
name: 'name',
sql: () => 'SELECT * FROM public.Orders',
dimensions: {
status: {
sql: () => 'status',
type: 'string',
format: '%Y-%m-%d'
},
},
fileName: 'fileName',
};

const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter());
expect(validationResult.error).toBeTruthy();
});
});
});
1 change: 1 addition & 0 deletions rust/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.idea
1 change: 1 addition & 0 deletions rust/cubesql/cubeclient/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
src/lib.rs
src/models/mod.rs
src/models/v1_cube_meta.rs
src/models/v1_cube_meta_custom_time_format.rs
src/models/v1_cube_meta_dimension.rs
src/models/v1_cube_meta_dimension_granularity.rs
src/models/v1_cube_meta_folder.rs
Expand Down
Loading
Loading