From 4a37d8cbab43715d25816bc08b9c3a3b35d0f5e2 Mon Sep 17 00:00:00 2001 From: Dmitry Patsura Date: Mon, 1 Dec 2025 11:55:38 +0100 Subject: [PATCH 1/7] feat: Support defining custom date formatting for time dimensions --- .../src/compiler/CubeToMetaTransformer.ts | 25 +++- .../src/compiler/CubeValidator.ts | 62 +++++++- .../test/unit/cube-validator.test.ts | 135 ++++++++++++++++++ 3 files changed, 219 insertions(+), 3 deletions(-) diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts index eb9c786757f12..710e11c88e64f 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts @@ -77,6 +77,8 @@ export type MeasureConfig = { public: boolean; }; +export type DimensionFormat = string | { type: string; label?: string; value?: string }; + export type DimensionConfig = { name: string; title: string; @@ -84,7 +86,7 @@ export type DimensionConfig = { description?: string; shortTitle: string; suggestFilterValues: boolean; - format?: string; + format?: DimensionFormat; meta?: any; isVisible: boolean; public: boolean; @@ -252,7 +254,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 +392,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-date', value: format }; + } } diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index 9df65fd95fc98..078eada13a966 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -119,6 +119,62 @@ const formatSchema = Joi.alternatives([ }) ]); +const DATE_FORMAT_SPECIFIERS = new Set(['d', 'm', 'y', 'h', 'n', 's', 'q', 'w']); +const DATE_FORMAT_SEPARATORS = new Set(['-', '/', ':', '.', ',', ' ']); + +const customDateFormatSchema = Joi.string().custom((value, helper) => { + const invalidChars: string[] = []; + let hasSpecifier = false; + + let i = 0; + + while (i < value.length) { + const char = value[i]; + + // Handle quoted literals (skip content inside quotes or brackets) + if (char === '"' || char === '\'') { + const closeIndex = value.indexOf(char, i + 1); + if (closeIndex === -1) { + return helper.message({ custom: `Invalid date format "${value}". Unclosed quote at position ${i}` }); + } + + i = closeIndex + 1; + } else if (char === '[') { + const closeIndex = value.indexOf(']', i + 1); + if (closeIndex === -1) { + return helper.message({ custom: `Invalid date format "${value}". Unclosed bracket at position ${i}` }); + } + + i = closeIndex + 1; + } else if (DATE_FORMAT_SPECIFIERS.has(char.toLowerCase())) { + hasSpecifier = true; + i++; + } else if (DATE_FORMAT_SEPARATORS.has(char)) { + i++; + } else if (char.toUpperCase() === 'A' || char.toUpperCase() === 'P') { + i++; + } else { + invalidChars.push(char); + i++; + } + } + + if (!hasSpecifier) { + return helper.message({ custom: `Invalid date format "${value}". Format must contain at least one date/time specifier (d, m, y, h, n, s, q, w)` }); + } + + if (invalidChars.length > 0) { + return helper.message({ custom: `Invalid date format "${value}". Contains invalid characters: "${invalidChars.join('')}". Use quotes for literal text.` }); + } + + return value; +}); + +const timeFormatSchema = Joi.alternatives([ + formatSchema, + customDateFormatSchema +]); + const BaseDimensionWithoutSubQuery = { aliases: Joi.array().items(Joi.string()), type: Joi.any().valid('string', 'number', 'boolean', 'time', 'geo').required(), @@ -131,7 +187,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..8f5fcf277b125 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,139 @@ describe('Cube Validation', () => { expect(result.error).toBeTruthy(); }); }); + + describe('Custom date format for time dimensions', () => { + it('time dimension with valid custom date 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: 'yyyy-MM-dd' + }, + }, + fileName: 'fileName', + }; + + const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter()); + expect(validationResult.error).toBeFalsy(); + }); + + it('time dimension with complex date 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: 'dd/mm/yyyy hh:nn:ss' + }, + }, + fileName: 'fileName', + }; + + const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter()); + expect(validationResult.error).toBeFalsy(); + }); + + it('time dimension with quoted literals 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: 'yyyy "Year" mm "Month"' + }, + }, + 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 characters in format - 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: 'yyyy@mm#dd' + }, + }, + fileName: 'fileName', + }; + + const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter()); + expect(validationResult.error).toBeTruthy(); + }); + + it('non-time dimension with date 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: 'yyyy-MM-dd' + }, + }, + fileName: 'fileName', + }; + + const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter()); + expect(validationResult.error).toBeTruthy(); + }); + }); }); From e3f49bdff8d34b38f34739f277049d323ae5b077 Mon Sep 17 00:00:00 2001 From: Dmitry Patsura Date: Mon, 1 Dec 2025 12:19:58 +0100 Subject: [PATCH 2/7] chore: update openspec/client-core --- packages/cubejs-api-gateway/openspec.yml | 17 ++++++++++++++++- packages/cubejs-client-core/src/types.ts | 8 +++++++- .../src/compiler/CubeToMetaTransformer.ts | 4 +++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/cubejs-api-gateway/openspec.yml b/packages/cubejs-api-gateway/openspec.yml index f83b0642b32af..dad06003aed81 100644 --- a/packages/cubejs-api-gateway/openspec.yml +++ b/packages/cubejs-api-gateway/openspec.yml @@ -291,11 +291,26 @@ components: required: - label - type + V1CubeMetaCustomDateFormat: + type: "object" + description: Custom date format for time dimensions + properties: + type: + type: "string" + enum: ["custom-date"] + description: Type of the format (must be 'custom-date') + value: + type: "string" + description: Date format string (e.g., 'yyyy-MM-dd', 'dd/mm/yyyy hh:nn:ss') + 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/V1CubeMetaCustomDateFormat" + description: Format of dimension - can be a simple string format, a link configuration, or a custom date 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..9c1a13407c021 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 DimensionCustomDateFormat = { type: 'custom-date'; value: string }; +export type DimensionLinkFormat = { type: 'link'; label: string }; +export type DimensionFormat = 'percent' | 'currency' | 'number' | 'imageUrl' | 'id' | 'link' + | DimensionLinkFormat | DimensionCustomDateFormat; + 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 710e11c88e64f..ec26fff1ccb46 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts @@ -77,7 +77,9 @@ export type MeasureConfig = { public: boolean; }; -export type DimensionFormat = string | { type: string; label?: string; value?: string }; +export type DimensionCustomDateFormat = { type: 'custom-date'; value: string }; +export type DimensionLinkFormat = { type: 'link'; label?: string }; +export type DimensionFormat = string | DimensionLinkFormat | DimensionCustomDateFormat; export type DimensionConfig = { name: string; From eced2da5a7cef17a7ed3ce2e8cfbc1c3bbd210be Mon Sep 17 00:00:00 2001 From: Dmitry Patsura Date: Mon, 1 Dec 2025 12:32:45 +0100 Subject: [PATCH 3/7] feat(cubeclient): Support custom date formatting --- rust/.gitignore | 1 + .../cubeclient/.openapi-generator/FILES | 1 + rust/cubesql/cubeclient/src/models/mod.rs | 4 ++ .../models/v1_cube_meta_custom_date_format.rs | 42 +++++++++++++++++++ .../src/models/v1_cube_meta_format.rs | 7 +++- 5 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 rust/.gitignore create mode 100644 rust/cubesql/cubeclient/src/models/v1_cube_meta_custom_date_format.rs 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..c7e34701c4914 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_date_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..b37d0f590d321 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_date_format; +pub use self::v1_cube_meta_custom_date_format::V1CubeMetaCustomDateFormat; +// problem with code-gen, let's rename it as re-export +pub use self::v1_cube_meta_custom_date_format::Type as V1CubeMetaCustomDateFormatType; 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_date_format.rs b/rust/cubesql/cubeclient/src/models/v1_cube_meta_custom_date_format.rs new file mode 100644 index 0000000000000..b182be1e65891 --- /dev/null +++ b/rust/cubesql/cubeclient/src/models/v1_cube_meta_custom_date_format.rs @@ -0,0 +1,42 @@ +/* + * 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}; + +/// V1CubeMetaCustomDateFormat : Custom date format for time dimensions +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct V1CubeMetaCustomDateFormat { + /// Type of the format (must be 'custom-date') + #[serde(rename = "type")] + pub r#type: Type, + /// Date format string (e.g., 'yyyy-MM-dd', 'dd/mm/yyyy hh:nn:ss') + #[serde(rename = "value")] + pub value: String, +} + +impl V1CubeMetaCustomDateFormat { + /// Custom date format for time dimensions + pub fn new(r#type: Type, value: String) -> V1CubeMetaCustomDateFormat { + V1CubeMetaCustomDateFormat { r#type, value } + } +} +/// Type of the format (must be 'custom-date') +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Type { + #[serde(rename = "custom-date")] + CustomDate, +} + +impl Default for Type { + fn default() -> Type { + Self::CustomDate + } +} 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..6a839db0b92ea 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 date format +/// Format of dimension - can be a simple string format, a link configuration, or a custom date format #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum V1CubeMetaFormat { V1CubeMetaSimpleFormat(models::V1CubeMetaSimpleFormat), V1CubeMetaLinkFormat(Box), + V1CubeMetaCustomDateFormat(Box), } impl Default for V1CubeMetaFormat { @@ -30,6 +31,8 @@ impl Default for V1CubeMetaFormat { pub enum Type { #[serde(rename = "link")] Link, + #[serde(rename = "custom-date")] + CustomDate, } impl Default for Type { From fa1e97657dcc3a4f34a22e7e23c935b93d84c4ec Mon Sep 17 00:00:00 2001 From: Dmitry Patsura Date: Mon, 1 Dec 2025 13:50:50 +0100 Subject: [PATCH 4/7] chore: rename to custom-time --- packages/cubejs-api-gateway/openspec.yml | 14 +++++------ packages/cubejs-client-core/src/types.ts | 4 ++-- .../src/compiler/CubeToMetaTransformer.ts | 6 ++--- .../src/compiler/CubeValidator.ts | 20 ++++++++-------- .../test/unit/cube-validator.test.ts | 8 +++---- .../cubeclient/.openapi-generator/FILES | 2 +- rust/cubesql/cubeclient/src/models/mod.rs | 6 ++--- ....rs => v1_cube_meta_custom_time_format.rs} | 24 +++++++++---------- .../src/models/v1_cube_meta_format.rs | 10 ++++---- 9 files changed, 47 insertions(+), 47 deletions(-) rename rust/cubesql/cubeclient/src/models/{v1_cube_meta_custom_date_format.rs => v1_cube_meta_custom_time_format.rs} (50%) diff --git a/packages/cubejs-api-gateway/openspec.yml b/packages/cubejs-api-gateway/openspec.yml index dad06003aed81..92cfa399bcb21 100644 --- a/packages/cubejs-api-gateway/openspec.yml +++ b/packages/cubejs-api-gateway/openspec.yml @@ -291,17 +291,17 @@ components: required: - label - type - V1CubeMetaCustomDateFormat: + V1CubeMetaCustomTimeFormat: type: "object" - description: Custom date format for time dimensions + description: Custom time format for time dimensions properties: type: type: "string" - enum: ["custom-date"] - description: Type of the format (must be 'custom-date') + enum: ["custom-time"] + description: Type of the format (must be 'custom-time') value: type: "string" - description: Date format string (e.g., 'yyyy-MM-dd', 'dd/mm/yyyy hh:nn:ss') + description: Time format string (e.g., 'yyyy-MM-dd', 'dd/mm/yyyy hh:nn:ss') required: - type - value @@ -309,8 +309,8 @@ components: oneOf: - $ref: "#/components/schemas/V1CubeMetaSimpleFormat" - $ref: "#/components/schemas/V1CubeMetaLinkFormat" - - $ref: "#/components/schemas/V1CubeMetaCustomDateFormat" - description: Format of dimension - can be a simple string format, a link configuration, or a custom date format + - $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 9c1a13407c021..5539644c7b955 100644 --- a/packages/cubejs-client-core/src/types.ts +++ b/packages/cubejs-client-core/src/types.ts @@ -15,10 +15,10 @@ export type GranularityAnnotation = { origin?: string; }; -export type DimensionCustomDateFormat = { type: 'custom-date'; value: 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 | DimensionCustomDateFormat; + | DimensionLinkFormat | DimensionCustomTimeFormat; export type Annotation = { title: string; diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts index ec26fff1ccb46..3ba0d9db2c2e8 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeToMetaTransformer.ts @@ -77,9 +77,9 @@ export type MeasureConfig = { public: boolean; }; -export type DimensionCustomDateFormat = { type: 'custom-date'; value: string }; +export type DimensionCustomTimeFormat = { type: 'custom-time'; value: string }; export type DimensionLinkFormat = { type: 'link'; label?: string }; -export type DimensionFormat = string | DimensionLinkFormat | DimensionCustomDateFormat; +export type DimensionFormat = string | DimensionLinkFormat | DimensionCustomTimeFormat; export type DimensionConfig = { name: string; @@ -411,6 +411,6 @@ export class CubeToMetaTransformer implements CompilerInterface { return format; } - return { type: 'custom-date', value: 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 078eada13a966..aab05ddf17391 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -119,10 +119,10 @@ const formatSchema = Joi.alternatives([ }) ]); -const DATE_FORMAT_SPECIFIERS = new Set(['d', 'm', 'y', 'h', 'n', 's', 'q', 'w']); -const DATE_FORMAT_SEPARATORS = new Set(['-', '/', ':', '.', ',', ' ']); +const TIME_FORMAT_SPECIFIERS = new Set(['d', 'm', 'y', 'h', 'n', 's', 'q', 'w']); +const TIME_FORMAT_SEPARATORS = new Set(['-', '/', ':', '.', ',', ' ']); -const customDateFormatSchema = Joi.string().custom((value, helper) => { +const customTimeFormatSchema = Joi.string().custom((value, helper) => { const invalidChars: string[] = []; let hasSpecifier = false; @@ -135,21 +135,21 @@ const customDateFormatSchema = Joi.string().custom((value, helper) => { if (char === '"' || char === '\'') { const closeIndex = value.indexOf(char, i + 1); if (closeIndex === -1) { - return helper.message({ custom: `Invalid date format "${value}". Unclosed quote at position ${i}` }); + return helper.message({ custom: `Invalid time format "${value}". Unclosed quote at position ${i}` }); } i = closeIndex + 1; } else if (char === '[') { const closeIndex = value.indexOf(']', i + 1); if (closeIndex === -1) { - return helper.message({ custom: `Invalid date format "${value}". Unclosed bracket at position ${i}` }); + return helper.message({ custom: `Invalid time format "${value}". Unclosed bracket at position ${i}` }); } i = closeIndex + 1; - } else if (DATE_FORMAT_SPECIFIERS.has(char.toLowerCase())) { + } else if (TIME_FORMAT_SPECIFIERS.has(char.toLowerCase())) { hasSpecifier = true; i++; - } else if (DATE_FORMAT_SEPARATORS.has(char)) { + } else if (TIME_FORMAT_SEPARATORS.has(char)) { i++; } else if (char.toUpperCase() === 'A' || char.toUpperCase() === 'P') { i++; @@ -160,11 +160,11 @@ const customDateFormatSchema = Joi.string().custom((value, helper) => { } if (!hasSpecifier) { - return helper.message({ custom: `Invalid date format "${value}". Format must contain at least one date/time specifier (d, m, y, h, n, s, q, w)` }); + return helper.message({ custom: `Invalid time format "${value}". Format must contain at least one date/time specifier (d, m, y, h, n, s, q, w)` }); } if (invalidChars.length > 0) { - return helper.message({ custom: `Invalid date format "${value}". Contains invalid characters: "${invalidChars.join('')}". Use quotes for literal text.` }); + return helper.message({ custom: `Invalid time format "${value}". Contains invalid characters: "${invalidChars.join('')}". Use quotes for literal text.` }); } return value; @@ -172,7 +172,7 @@ const customDateFormatSchema = Joi.string().custom((value, helper) => { const timeFormatSchema = Joi.alternatives([ formatSchema, - customDateFormatSchema + customTimeFormatSchema ]); const BaseDimensionWithoutSubQuery = { 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 8f5fcf277b125..d32abdafe82d2 100644 --- a/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts @@ -1293,8 +1293,8 @@ describe('Cube Validation', () => { }); }); - describe('Custom date format for time dimensions', () => { - it('time dimension with valid custom date format - correct', async () => { + describe('Custom time format for time dimensions', () => { + it('time dimension with valid custom time format - correct', async () => { const cubeValidator = new CubeValidator(new CubeSymbols()); const cube = { name: 'name', @@ -1313,7 +1313,7 @@ describe('Cube Validation', () => { expect(validationResult.error).toBeFalsy(); }); - it('time dimension with complex date format - correct', async () => { + it('time dimension with complex time format - correct', async () => { const cubeValidator = new CubeValidator(new CubeSymbols()); const cube = { name: 'name', @@ -1408,7 +1408,7 @@ describe('Cube Validation', () => { expect(validationResult.error).toBeTruthy(); }); - it('non-time dimension with date format string - error', async () => { + it('non-time dimension with time format string - error', async () => { const cubeValidator = new CubeValidator(new CubeSymbols()); const cube = { name: 'name', diff --git a/rust/cubesql/cubeclient/.openapi-generator/FILES b/rust/cubesql/cubeclient/.openapi-generator/FILES index c7e34701c4914..c9d7d2f8207bc 100644 --- a/rust/cubesql/cubeclient/.openapi-generator/FILES +++ b/rust/cubesql/cubeclient/.openapi-generator/FILES @@ -2,7 +2,7 @@ src/lib.rs src/models/mod.rs src/models/v1_cube_meta.rs -src/models/v1_cube_meta_custom_date_format.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 b37d0f590d321..8b336672b31b2 100644 --- a/rust/cubesql/cubeclient/src/models/mod.rs +++ b/rust/cubesql/cubeclient/src/models/mod.rs @@ -1,9 +1,9 @@ pub mod v1_cube_meta; pub use self::v1_cube_meta::V1CubeMeta; -pub mod v1_cube_meta_custom_date_format; -pub use self::v1_cube_meta_custom_date_format::V1CubeMetaCustomDateFormat; +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_date_format::Type as V1CubeMetaCustomDateFormatType; +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_date_format.rs b/rust/cubesql/cubeclient/src/models/v1_cube_meta_custom_time_format.rs similarity index 50% rename from rust/cubesql/cubeclient/src/models/v1_cube_meta_custom_date_format.rs rename to rust/cubesql/cubeclient/src/models/v1_cube_meta_custom_time_format.rs index b182be1e65891..909c68ae8767f 100644 --- a/rust/cubesql/cubeclient/src/models/v1_cube_meta_custom_date_format.rs +++ b/rust/cubesql/cubeclient/src/models/v1_cube_meta_custom_time_format.rs @@ -11,32 +11,32 @@ use crate::models; use serde::{Deserialize, Serialize}; -/// V1CubeMetaCustomDateFormat : Custom date format for time dimensions +/// V1CubeMetaCustomTimeFormat : Custom time format for time dimensions #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct V1CubeMetaCustomDateFormat { - /// Type of the format (must be 'custom-date') +pub struct V1CubeMetaCustomTimeFormat { + /// Type of the format (must be 'custom-time') #[serde(rename = "type")] pub r#type: Type, - /// Date format string (e.g., 'yyyy-MM-dd', 'dd/mm/yyyy hh:nn:ss') + /// Time format string (e.g., 'yyyy-MM-dd', 'dd/mm/yyyy hh:nn:ss') #[serde(rename = "value")] pub value: String, } -impl V1CubeMetaCustomDateFormat { - /// Custom date format for time dimensions - pub fn new(r#type: Type, value: String) -> V1CubeMetaCustomDateFormat { - V1CubeMetaCustomDateFormat { r#type, value } +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-date') +/// 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-date")] - CustomDate, + #[serde(rename = "custom-time")] + CustomTime, } impl Default for Type { fn default() -> Type { - Self::CustomDate + 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 6a839db0b92ea..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,14 +11,14 @@ use crate::models; use serde::{Deserialize, Serialize}; -/// V1CubeMetaFormat : Format of dimension - can be a simple string format, a link configuration, or a custom date format -/// Format of dimension - can be a simple string format, a link configuration, or a custom date format +/// 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), - V1CubeMetaCustomDateFormat(Box), + V1CubeMetaCustomTimeFormat(Box), } impl Default for V1CubeMetaFormat { @@ -31,8 +31,8 @@ impl Default for V1CubeMetaFormat { pub enum Type { #[serde(rename = "link")] Link, - #[serde(rename = "custom-date")] - CustomDate, + #[serde(rename = "custom-time")] + CustomTime, } impl Default for Type { From 909b1f0cb991a586f71039c2140a69c58489b345 Mon Sep 17 00:00:00 2001 From: Dmitry Patsura Date: Tue, 2 Dec 2025 11:35:08 +0100 Subject: [PATCH 5/7] chore: d3 time format --- packages/cubejs-api-gateway/openspec.yml | 2 +- .../src/compiler/CubeValidator.ts | 65 ++++++++------- .../test/unit/cube-validator.test.ts | 79 ++++++++++++++++--- 3 files changed, 105 insertions(+), 41 deletions(-) diff --git a/packages/cubejs-api-gateway/openspec.yml b/packages/cubejs-api-gateway/openspec.yml index 92cfa399bcb21..53359a9023113 100644 --- a/packages/cubejs-api-gateway/openspec.yml +++ b/packages/cubejs-api-gateway/openspec.yml @@ -301,7 +301,7 @@ components: description: Type of the format (must be 'custom-time') value: type: "string" - description: Time format string (e.g., 'yyyy-MM-dd', 'dd/mm/yyyy hh:nn:ss') + 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 diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index aab05ddf17391..4b4da9dc01d27 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -119,52 +119,59 @@ const formatSchema = Joi.alternatives([ }) ]); -const TIME_FORMAT_SPECIFIERS = new Set(['d', 'm', 'y', 'h', 'n', 's', 'q', 'w']); -const TIME_FORMAT_SEPARATORS = new Set(['-', '/', ':', '.', ',', ' ']); +// 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 STRPTIME_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) => { - const invalidChars: string[] = []; let hasSpecifier = false; - let i = 0; while (i < value.length) { - const char = value[i]; + if (value[i] === '%') { + if (i + 1 >= value.length) { + return helper.message({ custom: `Invalid strptime format "${value}". Incomplete specifier at end of string` }); + } - // Handle quoted literals (skip content inside quotes or brackets) - if (char === '"' || char === '\'') { - const closeIndex = value.indexOf(char, i + 1); - if (closeIndex === -1) { - return helper.message({ custom: `Invalid time format "${value}". Unclosed quote at position ${i}` }); + const specifier = value[i + 1]; + + if (!STRPTIME_SPECIFIERS.has(specifier)) { + return helper.message({ custom: `Invalid strptime format "${value}". Unknown specifier '%${specifier}'` }); } - i = closeIndex + 1; - } else if (char === '[') { - const closeIndex = value.indexOf(']', i + 1); - if (closeIndex === -1) { - return helper.message({ custom: `Invalid time format "${value}". Unclosed bracket at position ${i}` }); + // %% is an escape for literal %, not a date/time specifier + if (specifier !== '%') { + hasSpecifier = true; } - i = closeIndex + 1; - } else if (TIME_FORMAT_SPECIFIERS.has(char.toLowerCase())) { - hasSpecifier = true; - i++; - } else if (TIME_FORMAT_SEPARATORS.has(char)) { - i++; - } else if (char.toUpperCase() === 'A' || char.toUpperCase() === 'P') { - i++; + i += 2; } else { - invalidChars.push(char); + // Any other character is treated as literal text i++; } } if (!hasSpecifier) { - return helper.message({ custom: `Invalid time format "${value}". Format must contain at least one date/time specifier (d, m, y, h, n, s, q, w)` }); - } - - if (invalidChars.length > 0) { - return helper.message({ custom: `Invalid time format "${value}". Contains invalid characters: "${invalidChars.join('')}". Use quotes for literal text.` }); + return helper.message({ + custom: `Invalid strptime format "${value}". Format must contain at least one strptime specifier (e.g., %Y, %m, %d)` + }); } return value; 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 d32abdafe82d2..7a665ab31e89f 100644 --- a/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/cube-validator.test.ts @@ -1293,8 +1293,8 @@ describe('Cube Validation', () => { }); }); - describe('Custom time format for time dimensions', () => { - it('time dimension with valid custom time format - correct', async () => { + 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', @@ -1303,7 +1303,7 @@ describe('Cube Validation', () => { createdAt: { sql: () => 'created_at', type: 'time', - format: 'yyyy-MM-dd' + format: '%Y-%m-%d' }, }, fileName: 'fileName', @@ -1313,7 +1313,7 @@ describe('Cube Validation', () => { expect(validationResult.error).toBeFalsy(); }); - it('time dimension with complex time format - correct', async () => { + it('time dimension with complex strptime format - correct', async () => { const cubeValidator = new CubeValidator(new CubeSymbols()); const cube = { name: 'name', @@ -1322,7 +1322,7 @@ describe('Cube Validation', () => { createdAt: { sql: () => 'created_at', type: 'time', - format: 'dd/mm/yyyy hh:nn:ss' + format: '%d/%m/%Y %H:%M:%S' }, }, fileName: 'fileName', @@ -1332,7 +1332,7 @@ describe('Cube Validation', () => { expect(validationResult.error).toBeFalsy(); }); - it('time dimension with quoted literals in format - correct', async () => { + it('time dimension with literal text in format - correct', async () => { const cubeValidator = new CubeValidator(new CubeSymbols()); const cube = { name: 'name', @@ -1341,7 +1341,26 @@ describe('Cube Validation', () => { createdAt: { sql: () => 'created_at', type: 'time', - format: 'yyyy "Year" mm "Month"' + 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', @@ -1389,7 +1408,45 @@ describe('Cube Validation', () => { expect(validationResult.error).toBeTruthy(); }); - it('time dimension with invalid characters in format - error', async () => { + 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', @@ -1398,7 +1455,7 @@ describe('Cube Validation', () => { createdAt: { sql: () => 'created_at', type: 'time', - format: 'yyyy@mm#dd' + format: '%%' }, }, fileName: 'fileName', @@ -1408,7 +1465,7 @@ describe('Cube Validation', () => { expect(validationResult.error).toBeTruthy(); }); - it('non-time dimension with time format string - error', async () => { + it('non-time dimension with strptime format string - error', async () => { const cubeValidator = new CubeValidator(new CubeSymbols()); const cube = { name: 'name', @@ -1417,7 +1474,7 @@ describe('Cube Validation', () => { status: { sql: () => 'status', type: 'string', - format: 'yyyy-MM-dd' + format: '%Y-%m-%d' }, }, fileName: 'fileName', From aa7fbdfbf30486109f594af619713bc43a8476f9 Mon Sep 17 00:00:00 2001 From: Dmitry Patsura Date: Tue, 2 Dec 2025 12:14:14 +0100 Subject: [PATCH 6/7] chore: comment --- .../cubeclient/src/models/v1_cube_meta_custom_time_format.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 909c68ae8767f..cc602e0e9815b 100644 --- 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 @@ -17,7 +17,8 @@ pub struct V1CubeMetaCustomTimeFormat { /// Type of the format (must be 'custom-time') #[serde(rename = "type")] pub r#type: Type, - /// Time format string (e.g., 'yyyy-MM-dd', 'dd/mm/yyyy hh:nn:ss') + /// 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, } From c3d38decf9226267a3ec1dfe82ff98aac0c9941c Mon Sep 17 00:00:00 2001 From: Dmitry Patsura Date: Tue, 2 Dec 2025 13:13:18 +0100 Subject: [PATCH 7/7] chore: better comment --- .../cubejs-schema-compiler/src/compiler/CubeValidator.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index 4b4da9dc01d27..6b515439ad0e4 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -122,7 +122,7 @@ 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 STRPTIME_SPECIFIERS = new Set([ +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', @@ -147,13 +147,13 @@ const customTimeFormatSchema = Joi.string().custom((value, helper) => { while (i < value.length) { if (value[i] === '%') { if (i + 1 >= value.length) { - return helper.message({ custom: `Invalid strptime format "${value}". Incomplete specifier at end of string` }); + return helper.message({ custom: `Invalid time format "${value}". Incomplete specifier at end of string` }); } const specifier = value[i + 1]; - if (!STRPTIME_SPECIFIERS.has(specifier)) { - return helper.message({ custom: `Invalid strptime format "${value}". Unknown specifier '%${specifier}'` }); + 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