diff --git a/packages/cubejs-api-gateway/openspec.yml b/packages/cubejs-api-gateway/openspec.yml index f83b0642b32af..53359a9023113 100644 --- a/packages/cubejs-api-gateway/openspec.yml +++ b/packages/cubejs-api-gateway/openspec.yml @@ -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: diff --git a/packages/cubejs-client-core/src/types.ts b/packages/cubejs-client-core/src/types.ts index db50bd61fcd59..5539644c7b955 100644 --- a/packages/cubejs-client-core/src/types.ts +++ b/packages/cubejs-client-core/src/types.ts @@ -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; @@ -388,6 +393,7 @@ export type CubeTimeDimensionGranularity = { export type BaseCubeDimension = BaseCubeMember & { primaryKey?: boolean; suggestFilterValues: boolean; + format?: DimensionFormat; }; export type CubeTimeDimension = BaseCubeDimension & diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts index eb9c786757f12..3ba0d9db2c2e8 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts @@ -77,6 +77,10 @@ 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; @@ -84,7 +88,7 @@ export type DimensionConfig = { description?: string; shortTitle: string; suggestFilterValues: boolean; - format?: string; + format?: DimensionFormat; meta?: any; isVisible: boolean; public: boolean; @@ -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, @@ -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 }; + } } diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index 9df65fd95fc98..6b515439ad0e4 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -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(), @@ -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', diff --git a/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts b/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts index c70aa16f422f6..7a665ab31e89f 100644 --- a/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts @@ -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(); + }); + }); }); diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000000000..485dee64bcfb4 --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/rust/cubesql/cubeclient/.openapi-generator/FILES b/rust/cubesql/cubeclient/.openapi-generator/FILES index 91667358992ba..c9d7d2f8207bc 100644 --- a/rust/cubesql/cubeclient/.openapi-generator/FILES +++ b/rust/cubesql/cubeclient/.openapi-generator/FILES @@ -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 diff --git a/rust/cubesql/cubeclient/src/models/mod.rs b/rust/cubesql/cubeclient/src/models/mod.rs index 5ee41ef768ef0..8b336672b31b2 100644 --- a/rust/cubesql/cubeclient/src/models/mod.rs +++ b/rust/cubesql/cubeclient/src/models/mod.rs @@ -1,5 +1,9 @@ pub mod v1_cube_meta; pub use self::v1_cube_meta::V1CubeMeta; +pub mod v1_cube_meta_custom_time_format; +pub use self::v1_cube_meta_custom_time_format::V1CubeMetaCustomTimeFormat; +// problem with code-gen, let's rename it as re-export +pub use self::v1_cube_meta_custom_time_format::Type as V1CubeMetaCustomTimeFormatType; pub mod v1_cube_meta_dimension; pub use self::v1_cube_meta_dimension::V1CubeMetaDimension; pub mod v1_cube_meta_dimension_granularity; diff --git a/rust/cubesql/cubeclient/src/models/v1_cube_meta_custom_time_format.rs b/rust/cubesql/cubeclient/src/models/v1_cube_meta_custom_time_format.rs new file mode 100644 index 0000000000000..cc602e0e9815b --- /dev/null +++ b/rust/cubesql/cubeclient/src/models/v1_cube_meta_custom_time_format.rs @@ -0,0 +1,43 @@ +/* + * Cube.js + * + * Cube.js Swagger Schema + * + * The version of the OpenAPI document: 1.0.0 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::{Deserialize, Serialize}; + +/// V1CubeMetaCustomTimeFormat : Custom time format for time dimensions +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct V1CubeMetaCustomTimeFormat { + /// Type of the format (must be 'custom-time') + #[serde(rename = "type")] + pub r#type: Type, + /// 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 + #[serde(rename = "value")] + pub value: String, +} + +impl V1CubeMetaCustomTimeFormat { + /// Custom time format for time dimensions + pub fn new(r#type: Type, value: String) -> V1CubeMetaCustomTimeFormat { + V1CubeMetaCustomTimeFormat { r#type, value } + } +} +/// Type of the format (must be 'custom-time') +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Type { + #[serde(rename = "custom-time")] + CustomTime, +} + +impl Default for Type { + fn default() -> Type { + Self::CustomTime + } +} diff --git a/rust/cubesql/cubeclient/src/models/v1_cube_meta_format.rs b/rust/cubesql/cubeclient/src/models/v1_cube_meta_format.rs index 881d388975753..c1625fcc8f62e 100644 --- a/rust/cubesql/cubeclient/src/models/v1_cube_meta_format.rs +++ b/rust/cubesql/cubeclient/src/models/v1_cube_meta_format.rs @@ -11,13 +11,14 @@ use crate::models; use serde::{Deserialize, Serialize}; -/// V1CubeMetaFormat : Format of dimension - can be either a simple string format or an object with link configuration -/// Format of dimension - can be either a simple string format or an object with link configuration +/// V1CubeMetaFormat : Format of dimension - can be a simple string format, a link configuration, or a custom time format +/// Format of dimension - can be a simple string format, a link configuration, or a custom time format #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum V1CubeMetaFormat { V1CubeMetaSimpleFormat(models::V1CubeMetaSimpleFormat), V1CubeMetaLinkFormat(Box), + V1CubeMetaCustomTimeFormat(Box), } impl Default for V1CubeMetaFormat { @@ -30,6 +31,8 @@ impl Default for V1CubeMetaFormat { pub enum Type { #[serde(rename = "link")] Link, + #[serde(rename = "custom-time")] + CustomTime, } impl Default for Type {