diff --git a/docs/pages/product/data-modeling/recipes/_meta.js b/docs/pages/product/data-modeling/recipes/_meta.js index b541d8d869c18..1dc3a19ad8530 100644 --- a/docs/pages/product/data-modeling/recipes/_meta.js +++ b/docs/pages/product/data-modeling/recipes/_meta.js @@ -9,6 +9,7 @@ module.exports = { "using-dynamic-measures": "Dynamic data modeling", "dynamic-union-tables": "Dynamic union tables", "string-time-dimensions": "String time dimensions", + "local-time-dimensions": "Local time dimensions", "custom-granularity": "Custom time dimension granularities", "custom-calendar": "Custom calendars", "entity-attribute-value": "EAV model", diff --git a/docs/pages/product/data-modeling/recipes/local-time-dimensions.mdx b/docs/pages/product/data-modeling/recipes/local-time-dimensions.mdx new file mode 100644 index 0000000000000..ddf3bc6e2fb07 --- /dev/null +++ b/docs/pages/product/data-modeling/recipes/local-time-dimensions.mdx @@ -0,0 +1,260 @@ +# Local Time Dimensions + +This guide demonstrates how to use the `localTime` property for time dimensions +that represent pre-converted local timestamps in your data warehouse. + +## Use Case + +When you store timestamps that are already converted to a specific timezone +(e.g., business local time), you want to: + +- Use time dimension features like `dateRange`, `granularity`, and relative dates +- **Avoid** automatic timezone conversion by Cube +- Preserve the local time values as-is in both SQL generation and results + +This is particularly useful for: + +- Multi-location businesses with data in each location's timezone +- Analyzing patterns by business hours (not UTC) +- Pre-aggregated data already in local time +- Sales reports and operational dashboards based on local business days + +## Problem + +By default, Cube time dimensions have two mutually exclusive behaviors: + +- **`type: time`** - Supports time features BUT always applies timezone conversion +- **`type: string`** - No timezone conversion BUT loses all time dimension features + +## Solution + +Use the `localTime: true` property on time dimensions: + + + +```javascript +cube(`Orders`, { + sql_table: `orders`, + + dimensions: { + // Regular time dimension - timezone conversion applied + created_at: { + sql: `created_at`, + type: `time`, + }, + + // Local time dimension - no timezone conversion + local_date: { + sql: `DATE_FORMAT(from_utc_timestamp(created_at, ${Location.timezone}), 'yyyy-MM-dd')`, + type: `time`, + localTime: true, + description: `Date in the location's local timezone`, + }, + + local_hour: { + sql: `DATE_FORMAT(from_utc_timestamp(created_at, ${Location.timezone}), 'yyyy-MM-dd HH:00:00')`, + type: `time`, + localTime: true, + description: `Hour in the location's local timezone`, + }, + }, +}); +``` + +```yaml +cubes: + - name: Orders + sql_table: orders + + dimensions: + # Regular time dimension - timezone conversion applied + - name: created_at + sql: created_at + type: time + + # Local time dimension - no timezone conversion + - name: local_date + sql: "DATE_FORMAT(from_utc_timestamp(created_at, {Location.timezone}), 'yyyy-MM-dd')" + type: time + local_time: true + description: Date in the location's local timezone + + - name: local_hour + sql: "DATE_FORMAT(from_utc_timestamp(created_at, {Location.timezone}), 'yyyy-MM-dd HH:00:00')" + type: time + local_time: true + description: Hour in the location's local timezone +``` + + + +## Behavior + +When `localTime: true` is set: + +### ✅ Enabled Features + +- `dateRange` with relative dates: `"last month"`, `"yesterday"`, `"this year"` +- `granularity` for aggregation: `"day"`, `"hour"`, `"month"` +- Date range pickers in UI tools +- All standard time dimension query features + +### ❌ Disabled Conversions + +- Query `timezone` parameter is **not applied** to this dimension +- SQL generation does **not wrap** the dimension with timezone conversion functions +- Result values are **not converted** to the query timezone + +## Example Queries + +### Using dateRange with Local Time + +```json +{ + "measures": ["Orders.count"], + "timeDimensions": [ + { + "dimension": "Orders.local_date", + "dateRange": "last month", + "granularity": "day" + } + ], + "timezone": "America/New_York" +} +``` + +In this query: +- `Orders.local_date` uses the pre-converted local timezone (from the SQL) +- The `timezone: "America/New_York"` applies to other time dimensions, but **not** to `local_date` +- `dateRange: "last month"` works as expected, filtering on local dates + +### Multi-Location Analysis + +```json +{ + "measures": ["Orders.count", "Orders.total_amount"], + "dimensions": ["Orders.location_id"], + "timeDimensions": [ + { + "dimension": "Orders.local_hour", + "dateRange": "last 7 days", + "granularity": "hour" + } + ] +} +``` + +This query analyzes orders by hour in each location's local timezone, enabling +accurate "busiest hour" analysis across multiple timezones. + +## Comparison + +### Without localTime (Standard Behavior) + +```javascript +dimensions: { + local_date: { + sql: `DATE_FORMAT(from_utc_timestamp(created_at, 'America/Los_Angeles'), 'yyyy-MM-dd')`, + type: `string`, // Must use string to avoid double conversion + } +} +``` + +**Limitations:** +- ❌ Cannot use `dateRange: "last month"` +- ❌ Cannot use `granularity` +- ❌ Must provide exact date strings: `["2025-10-01", "2025-10-31"]` + +### With localTime (New Behavior) + +```javascript +dimensions: { + local_date: { + sql: `DATE_FORMAT(from_utc_timestamp(created_at, 'America/Los_Angeles'), 'yyyy-MM-dd')`, + type: `time`, + localTime: true, + } +} +``` + +**Benefits:** +- ✅ Can use `dateRange: "last month"` +- ✅ Can use `granularity: "day"` +- ✅ All time dimension features available +- ✅ No timezone conversion applied + +## Database-Specific Examples + +### Databricks + +```javascript +dimensions: { + local_date: { + sql: `DATE_FORMAT(from_utc_timestamp(${CUBE}.created_at, ${Location.timezone}), 'yyyy-MM-dd')`, + type: `time`, + localTime: true, + } +} +``` + +### PostgreSQL + +```javascript +dimensions: { + local_date: { + sql: `(${CUBE}.created_at AT TIME ZONE 'UTC' AT TIME ZONE ${Location.timezone})::date`, + type: `time`, + localTime: true, + } +} +``` + +### BigQuery + +```javascript +dimensions: { + local_date: { + sql: `DATE(${CUBE}.created_at, ${Location.timezone})`, + type: `time`, + localTime: true, + } +} +``` + +### MySQL + +```javascript +dimensions: { + local_date: { + sql: `CONVERT_TZ(${CUBE}.created_at, 'UTC', ${Location.timezone})`, + type: `time`, + localTime: true, + } +} +``` + +## Important Notes + +1. **The SQL must return timestamp-compatible values** - The dimension SQL should + return values in a format that your database recognizes as timestamps (even + though they're in local time). + +2. **Consistent timezone in SQL** - Ensure your dimension SQL consistently + converts to the same timezone. Mixing timezones will produce incorrect results. + +3. **Backward compatible** - Existing time dimensions without `localTime` behave + exactly as before. This is an opt-in feature. + +4. **Works with granularities** - Custom granularities defined on the dimension + work correctly with `localTime: true`. + +## See Also + +- [Time Dimensions][ref-time-dimensions] +- [Dimensions Reference][ref-dimensions] +- [Working with String Time Dimensions][ref-string-time-dimensions] + +[ref-time-dimensions]: /product/data-modeling/reference/types-and-formats#time +[ref-dimensions]: /product/data-modeling/reference/dimensions +[ref-string-time-dimensions]: /product/data-modeling/recipes/string-time-dimensions + diff --git a/docs/pages/product/data-modeling/reference/dimensions.mdx b/docs/pages/product/data-modeling/reference/dimensions.mdx index e4d088b76dfe3..b279e13913ca7 100644 --- a/docs/pages/product/data-modeling/reference/dimensions.mdx +++ b/docs/pages/product/data-modeling/reference/dimensions.mdx @@ -785,6 +785,60 @@ cube(`fiscal_calendar`, { +### `local_time` + +The `local_time` parameter is used with time dimensions to indicate that the +dimension SQL already returns values in a local (business) timezone and should +not be converted by Cube's automatic timezone conversion logic. + +When `local_time` is set to `true`: +- The dimension retains all time dimension features (`dateRange`, `granularity`, etc.) +- The query `timezone` parameter is **not applied** to this dimension +- Results are **not converted** to the query timezone + +This is useful when your data warehouse stores timestamps that are pre-converted +to specific local timezones, such as business operating hours or location-specific +time values. + + + +```javascript +cube(`orders`, { + // ... + + dimensions: { + local_date: { + sql: `DATE_FORMAT(from_utc_timestamp(created_at, 'America/Los_Angeles'), 'yyyy-MM-dd')`, + type: `time`, + local_time: true, + description: `Date in the location's local timezone` + } + } +}) +``` + +```yaml +cubes: + - name: orders + # ... + + dimensions: + - name: local_date + sql: "DATE_FORMAT(from_utc_timestamp(created_at, 'America/Los_Angeles'), 'yyyy-MM-dd')" + type: time + local_time: true + description: Date in the location's local timezone +``` + + + + + +See the [Local Time Dimensions recipe][ref-local-time-dimensions] for detailed +examples and use cases. + + + ### `time_shift` The `time_shift` parameter allows overriding the time shift behavior for time dimensions @@ -908,3 +962,4 @@ cube(`fiscal_calendar`, { [ref-time-shift]: /product/data-modeling/concepts/multi-stage-calculations#time-shift [ref-cube-calendar]: /product/data-modeling/reference/cube#calendar [ref-measure-time-shift]: /product/data-modeling/reference/measures#time_shift +[ref-local-time-dimensions]: /product/data-modeling/recipes/local-time-dimensions diff --git a/packages/cubejs-api-gateway/src/graphql.ts b/packages/cubejs-api-gateway/src/graphql.ts index 46c23d3e0708b..162f6fca72ed7 100644 --- a/packages/cubejs-api-gateway/src/graphql.ts +++ b/packages/cubejs-api-gateway/src/graphql.ts @@ -346,16 +346,36 @@ function whereArgToQueryFilters( function parseDates(result: any) { const { timezone } = result.query; - const dateKeys = Object.entries({ + const allAnnotations = { ...result.annotation.measures, ...result.annotation.dimensions, ...result.annotation.timeDimensions, - }).reduce((res, [key, value]) => (value.type === 'time' ? [...res, key] : res), [] as any); + }; + + const dateKeys = Object.entries(allAnnotations) + .reduce((res, [key, value]) => (value.type === 'time' ? [...res, key] : res), [] as any); result.data.forEach(row => { Object.keys(row).forEach(key => { if (dateKeys.includes(key)) { - row[key] = moment.tz(row[key], timezone).toISOString(); + const annotation = allAnnotations[key]; + if (annotation && annotation.localTime) { + // For localTime dimensions, format as ISO string without timezone conversion + // The timestamp is already in local time, we just need to ensure it's in ISO format + if (row[key]) { + const dateStr = row[key].toString(); + // If it already has 'Z' suffix, use as-is. Otherwise, add it for ISO compliance. + if (dateStr.endsWith('Z')) { + row[key] = dateStr; + } else { + // Parse without timezone and format as UTC (treating local time as UTC) + const m = moment.utc(row[key]); + row[key] = m.toISOString(); + } + } + } else { + row[key] = moment.tz(row[key], timezone).toISOString(); + } } return row; }); diff --git a/packages/cubejs-api-gateway/src/helpers/prepareAnnotation.ts b/packages/cubejs-api-gateway/src/helpers/prepareAnnotation.ts index db0385e7248d4..dd3b8bb565fae 100644 --- a/packages/cubejs-api-gateway/src/helpers/prepareAnnotation.ts +++ b/packages/cubejs-api-gateway/src/helpers/prepareAnnotation.ts @@ -33,6 +33,7 @@ type ConfigItem = { drillMembers?: any[]; drillMembersGrouped?: any; granularities?: GranularityMeta[]; + localTime?: boolean; }; type AnnotatedConfigItem = Omit & { @@ -69,6 +70,9 @@ const annotation = ( ...(memberType === MemberTypeEnum.DIMENSIONS && config.granularities ? { granularities: config.granularities || [], } : {}), + ...(memberType === MemberTypeEnum.DIMENSIONS && config.localTime ? { + localTime: config.localTime, + } : {}), }]; }; diff --git a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js index 037361854675c..1c6089e5806d7 100644 --- a/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js +++ b/packages/cubejs-schema-compiler/src/adapter/BaseQuery.js @@ -2075,7 +2075,15 @@ export class BaseQuery { * @return {string} */ timeStampParam(timeDimension) { - return timeDimension.dateFieldType() === 'string' ? '?' : this.timeStampCast('?'); + if (timeDimension.dateFieldType() === 'string') { + return '?'; + } + // For localTime dimensions, use timestamp (without timezone) instead of timestamptz + const dimDef = timeDimension.dimensionDefinition && timeDimension.dimensionDefinition(); + if (dimDef && dimDef.localTime) { + return this.dateTimeCast('?'); + } + return this.timeStampCast('?'); } timeRangeFilter(dimensionSql, fromTimeStampParam, toTimeStampParam) { @@ -3404,6 +3412,7 @@ export class BaseQuery { !this.safeEvaluateSymbolContext().ignoreConvertTzForTimeDimension && !memberExpressionType && symbol.type === 'time' && + !symbol.localTime && this.cubeEvaluator.byPathAnyType(memberPathArray).ownedByCube ) { res = this.convertTz(res); diff --git a/packages/cubejs-schema-compiler/src/adapter/BaseTimeDimension.ts b/packages/cubejs-schema-compiler/src/adapter/BaseTimeDimension.ts index 0cdf2557f1515..070de19aa7a07 100644 --- a/packages/cubejs-schema-compiler/src/adapter/BaseTimeDimension.ts +++ b/packages/cubejs-schema-compiler/src/adapter/BaseTimeDimension.ts @@ -111,11 +111,17 @@ export class BaseTimeDimension extends BaseFilter { return context.renderedReference[path]; } + const dimDefinition = this.dimensionDefinition() as DimensionDefinition; + const isLocalTime = dimDefinition.localTime; + if (context.rollupQuery || context.wrapQuery) { if (context.rollupGranularity === this.granularityObj?.granularity) { return super.dimensionSql(); } + if (isLocalTime && granularity) { + return this.localTimeGroupedColumn(this.query.dimensionSql(this), granularity); + } return this.query.dimensionTimeGroupedColumn(this.query.dimensionSql(this), granularity); } @@ -123,9 +129,29 @@ export class BaseTimeDimension extends BaseFilter { return this.convertedToTz(); } + // For localTime dimensions with granularity, use UTC timezone for grouping + if (isLocalTime && granularity) { + return this.localTimeGroupedColumn(this.convertedToTz(), granularity); + } + return this.query.dimensionTimeGroupedColumn(this.convertedToTz(), granularity); } + /** + * For localTime dimensions, apply time grouping without timezone conversion. + * This uses UTC as the timezone to preserve the local time values. + */ + private localTimeGroupedColumn(dimension: string, granularity: Granularity): string { + // Temporarily override the query's timezone to UTC for grouping + const originalTimezone = this.query.timezone; + try { + (this.query as any).timezone = 'UTC'; + return this.query.dimensionTimeGroupedColumn(dimension, granularity); + } finally { + (this.query as any).timezone = originalTimezone; + } + } + public dimensionDefinition(): DimensionDefinition | SegmentDefinition { if (this.shiftInterval) { return { ...super.dimensionDefinition(), shiftInterval: this.shiftInterval }; @@ -138,6 +164,11 @@ export class BaseTimeDimension extends BaseFilter { } public convertedToTz() { + const dimDefinition = this.dimensionDefinition() as DimensionDefinition; + // Skip timezone conversion for local time dimensions + if (dimDefinition.localTime) { + return this.query.dimensionSql(this); + } return this.query.convertTz(this.query.dimensionSql(this)); } @@ -159,7 +190,15 @@ export class BaseTimeDimension extends BaseFilter { public dateFromFormatted() { if (!this.dateFromFormattedValue) { - this.dateFromFormattedValue = this.formatFromDate(this.dateRange[0]); + const formatted = this.formatFromDate(this.dateRange[0]); + const dimDefinition = this.dimensionDefinition() as DimensionDefinition; + // For local time dimensions, remove any ISO 8601 timezone suffix + // This includes: Z (UTC), +hh:mm, -hh:mm, +hhmm, -hhmm, +hh, -hh + if (dimDefinition.localTime) { + this.dateFromFormattedValue = this.stripTimezoneSuffix(formatted); + } else { + this.dateFromFormattedValue = formatted; + } } return this.dateFromFormattedValue; @@ -176,6 +215,11 @@ export class BaseTimeDimension extends BaseFilter { } public dateFromParam() { + const dimDefinition = this.dimensionDefinition() as DimensionDefinition; + // For local time dimensions, use local datetime params (no timezone conversion) + if (dimDefinition.localTime) { + return this.localDateTimeFromParam(); + } return this.query.paramAllocator.allocateParamsForQuestionString( this.query.timeStampParam(this), [this.dateFrom()] ); @@ -193,12 +237,159 @@ export class BaseTimeDimension extends BaseFilter { public dateToFormatted() { if (!this.dateToFormattedValue) { - this.dateToFormattedValue = this.formatToDate(this.dateRange[1]); + const formatted = this.formatToDate(this.dateRange[1]); + const dimDefinition = this.dimensionDefinition() as DimensionDefinition; + // For local time dimensions, remove any ISO 8601 timezone suffix + // This includes: Z (UTC), +hh:mm, -hh:mm, +hhmm, -hhmm, +hh, -hh + if (dimDefinition.localTime) { + this.dateToFormattedValue = this.stripTimezoneSuffix(formatted); + } else { + this.dateToFormattedValue = formatted; + } } return this.dateToFormattedValue; } + /** + * Strips ISO 8601 timezone designators from a datetime string. + * Handles all valid ISO 8601 timezone formats: + * - Z (UTC) + * - ±hh:mm (e.g., +05:30, -08:00) + * - ±hhmm (e.g., +0530, -0800) + * - ±hh (e.g., +05, -08) + * + * Only strips timezone info from timestamps (containing 'T'), not from date-only strings. + */ + private stripTimezoneSuffix(dateString: string): string { + if (!dateString) { + return dateString; + } + + // Only strip timezone if this is a timestamp (contains 'T'), not a date-only string + if (dateString.includes('T')) { + // Match ISO 8601 timezone designators at the end of the string: + // Z | [+-]hh:mm | [+-]hhmm | [+-]hh + return dateString.replace(/(?:Z|[+-]\d{2}(?::?\d{2})?)$/, ''); + } + + return dateString; + } + + /** + * Override formatFromDate for localTime dimensions to completely skip timezone conversion. + * For localTime dimensions, we want to preserve the exact datetime value without any timezone shifts. + */ + public formatFromDate(date: string): string { + const dimDefinition = this.dimensionDefinition() as DimensionDefinition; + if (dimDefinition.localTime && date) { + // Strip timezone suffix from input and format without timezone conversion + const strippedDate = this.stripTimezoneSuffix(date); + + // Format directly without timezone conversion + return this.formatLocalDateTime(strippedDate, true); + } + return super.formatFromDate(date); + } + + /** + * Override formatToDate for localTime dimensions to completely skip timezone conversion. + * For localTime dimensions, we want to preserve the exact datetime value without any timezone shifts. + */ + public formatToDate(date: string): string { + const dimDefinition = this.dimensionDefinition() as DimensionDefinition; + if (dimDefinition.localTime && date) { + // Strip timezone suffix from input and format without timezone conversion + const strippedDate = this.stripTimezoneSuffix(date); + // Format directly without timezone conversion + return this.formatLocalDateTime(strippedDate, false); + } + return super.formatToDate(date); + } + + /** + * Override inDbTimeZoneDateFrom for localTime dimensions to skip timezone conversion. + * For localTime dimensions, we want to use the formatted date directly without converting to DB timezone. + */ + public inDbTimeZoneDateFrom(date: any): any { + const dimDefinition = this.dimensionDefinition() as DimensionDefinition; + if (dimDefinition.localTime) { + // For localTime, return the formatted date without timezone conversion + return this.formatFromDate(date); + } + return super.inDbTimeZoneDateFrom(date); + } + + /** + * Override inDbTimeZoneDateTo for localTime dimensions to skip timezone conversion. + * For localTime dimensions, we want to use the formatted date directly without converting to DB timezone. + */ + public inDbTimeZoneDateTo(date: any): any { + const dimDefinition = this.dimensionDefinition() as DimensionDefinition; + if (dimDefinition.localTime) { + // For localTime, return the formatted date without timezone conversion + return this.formatToDate(date); + } + return super.inDbTimeZoneDateTo(date); + } + + /** + * Format a datetime string for localTime dimensions without applying timezone conversion. + * This ensures the datetime value stays exactly as specified, treating it as local time. + */ + private formatLocalDateTime(date: string, isFromDate: boolean): string { + if (!date) { + return date; + } + + const dateTimeLocalMsRegex = /^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d\.\d\d\d$/; + const dateTimeLocalURegex = /^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d\.\d\d\d\d\d\d$/; + const dateRegex = /^\d\d\d\d-\d\d-\d\d$/; + + const precision = this.query.timestampPrecision(); + + // If already in correct format with correct precision, return as-is + if (precision === 3 && date.match(dateTimeLocalMsRegex)) { + return date; + } + if (precision === 6) { + if (date.length === 23 && date.match(dateTimeLocalMsRegex)) { + // Handle special case for formatToDate with .999 + if (!isFromDate && date.endsWith('.999')) { + return `${date}999`; + } + return `${date}000`; + } + if (date.length === 26 && date.match(dateTimeLocalURegex)) { + return date; + } + } + + // Handle date-only format (YYYY-MM-DD) + if (date.match(dateRegex)) { + const time = isFromDate ? '00:00:00' : '23:59:59'; + const fractional = isFromDate ? '0'.repeat(precision) : '9'.repeat(precision); + return `${date}T${time}.${fractional}`; + } + + // Parse the date WITHOUT timezone conversion using moment() instead of moment.tz() + const m = moment(date); + if (!m.isValid()) { + return date; + } + + // Format based on whether this is a from or to date + if (isFromDate) { + return m.format(`YYYY-MM-DDTHH:mm:ss.${'S'.repeat(precision)}`); + } else { + // For "to" dates, if time is exactly midnight, set to end of day + if (m.format('HH:mm:ss') === '00:00:00') { + return m.format(`YYYY-MM-DDT23:59:59.${'9'.repeat(precision)}`); + } + return m.format(`YYYY-MM-DDTHH:mm:ss.${'S'.repeat(precision)}`); + } + } + protected dateToValue: any | null = null; public dateTo() { @@ -209,6 +400,11 @@ export class BaseTimeDimension extends BaseFilter { } public dateToParam() { + const dimDefinition = this.dimensionDefinition() as DimensionDefinition; + // For local time dimensions, use local datetime params (no timezone conversion) + if (dimDefinition.localTime) { + return this.localDateTimeToParam(); + } return this.query.paramAllocator.allocateParamsForQuestionString( this.query.timeStampParam(this), [this.dateTo()] ); diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts index 058b6fcb999a4..57a5c3554c38a 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeEvaluator.ts @@ -35,6 +35,7 @@ export type DimensionDefinition = { fieldType?: string; multiStage?: boolean; shiftInterval?: string; + localTime?: boolean; }; export type TimeShiftDefinition = { diff --git a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts index 9df65fd95fc98..028e2bea7d112 100644 --- a/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts +++ b/packages/cubejs-schema-compiler/src/compiler/CubeValidator.ts @@ -138,6 +138,11 @@ const BaseDimensionWithoutSubQuery = { then: Joi.array().items(Joi.string()), otherwise: Joi.forbidden() }), + localTime: Joi.when('type', { + is: 'time', + then: Joi.boolean().strict(), + otherwise: Joi.forbidden() + }), granularities: Joi.when('type', { is: 'time', then: Joi.object().pattern(identifierRegex, diff --git a/packages/cubejs-schema-compiler/test/integration/clickhouse/clickhouse-local-time.test.ts b/packages/cubejs-schema-compiler/test/integration/clickhouse/clickhouse-local-time.test.ts new file mode 100644 index 0000000000000..4a5efbc26786f --- /dev/null +++ b/packages/cubejs-schema-compiler/test/integration/clickhouse/clickhouse-local-time.test.ts @@ -0,0 +1,126 @@ +import { prepareYamlCompiler } from '../../unit/PrepareCompiler'; +import { ClickHouseQuery } from '../../../src/adapter/ClickHouseQuery'; +import { ClickHouseDbRunner } from './ClickHouseDbRunner'; + +describe('ClickHouse Local Time Dimensions', () => { + jest.setTimeout(200000); + + const dbRunner = new ClickHouseDbRunner(); + + const { compiler, joinGraph, cubeEvaluator } = prepareYamlCompiler( + ` + cubes: + - name: orders + sql: > + SELECT order_id, + created_at, + local_timestamp + FROM ( + SELECT + num + 1 AS order_id, + toDateTime('2024-01-01 00:00:00', 'UTC') + INTERVAL (num * 6) HOUR AS created_at, + toDateTime('2024-01-01 23:00:00', 'UTC') + INTERVAL (num * 6) HOUR AS local_timestamp + FROM ( + SELECT number AS num + FROM system.numbers + LIMIT 5 + ) + ) AS subquery + + dimensions: + - name: order_id + sql: order_id + type: number + primary_key: true + + - name: createdAt + sql: created_at + type: time + + - name: localTimestamp + sql: local_timestamp + type: time + local_time: true + + measures: + - name: count + type: count + + - name: minLocalTimestamp + type: min + sql: local_timestamp + `, + ClickHouseQuery + ); + + it('localTime dimension filters on local time value ignoring query timezone', async () => dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.localTimestamp', + dateRange: ['2024-01-01', '2024-01-01'] + }], + timezone: 'Europe/Athens', // +02:00 - would make 2024-01-01T23:00:00Z appear as 2024-01-02T01:00:00 + order: [{ id: 'orders.localTimestamp', desc: false }], + }, + [ + { orders__count: '1' } + ], + { joinGraph, cubeEvaluator, compiler } + )); + + it('localTime dimension with day granularity returns local date', async () => dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [ + { + dimension: 'orders.createdAt', + granularity: 'day' + }, + { + dimension: 'orders.localTimestamp', + granularity: 'day' + } + ], + filters: [{ + member: 'orders.createdAt', + operator: 'equals', + values: ['2024-01-01'] + }], + timezone: 'Europe/Athens', // +02:00 + order: [{ id: 'orders.createdAt', desc: false }], + }, + [ + { + orders__count: '1', + orders__created_at_day: '2024-01-01T00:00:00.000', + orders__local_timestamp_day: '2024-01-01T00:00:00.000', // Local date, not Athens date + } + ], + { joinGraph, cubeEvaluator, compiler } + )); + + it('localTime dimension comparison shows it stays in local time while regular dimension converts', async () => dbRunner.runQueryTest( + { + measures: ['orders.count', 'orders.minLocalTimestamp'], + timeDimensions: [ + { + dimension: 'orders.createdAt', + granularity: 'day', + dateRange: ['2024-01-01', '2024-01-01'] + } + ], + timezone: 'Europe/Athens', // +02:00 + order: [{ id: 'orders.createdAt', desc: false }], + }, + [ + { + orders__count: '4', // Only 4 records on 2024-01-01 in Athens timezone + orders__created_at_day: '2024-01-01T00:00:00.000', + orders__min_local_timestamp: '2024-01-01T23:00:00.000', // Returns local time without timezone conversion + } + ], + { joinGraph, cubeEvaluator, compiler } + )); +}); + diff --git a/packages/cubejs-schema-compiler/test/integration/mssql/mssql-local-time.test.ts b/packages/cubejs-schema-compiler/test/integration/mssql/mssql-local-time.test.ts new file mode 100644 index 0000000000000..288cbd7232981 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/integration/mssql/mssql-local-time.test.ts @@ -0,0 +1,121 @@ +import { prepareYamlCompiler } from '../../unit/PrepareCompiler'; +import { MssqlQuery } from '../../../src/adapter/MssqlQuery'; +import { MSSqlDbRunner } from './MSSqlDbRunner'; + +describe('MSSQL Local Time Dimensions', () => { + jest.setTimeout(200000); + + const dbRunner = new MSSqlDbRunner(); + + const { compiler, joinGraph, cubeEvaluator } = prepareYamlCompiler( + ` + cubes: + - name: orders + sql: > + SELECT order_id, + created_at, + local_timestamp + FROM ( + SELECT num + 1 AS order_id, + DATEADD(HOUR, num * 6, CAST('2024-01-01T00:00:00' AS DATETIME2)) AS created_at, + DATEADD(HOUR, num * 6, CAST('2024-01-01T23:00:00' AS DATETIME2)) AS local_timestamp + FROM (VALUES (0),(1),(2),(3),(4)) AS t(num) + ) AS subquery + + dimensions: + - name: order_id + sql: order_id + type: number + primary_key: true + + - name: createdAt + sql: created_at + type: time + + - name: localTimestamp + sql: local_timestamp + type: time + local_time: true + + measures: + - name: count + type: count + + - name: minLocalTimestamp + type: min + sql: local_timestamp + `, + MssqlQuery + ); + + it('localTime dimension filters on local time value ignoring query timezone', async () => dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.localTimestamp', + dateRange: ['2024-01-01', '2024-01-01'] + }], + timezone: 'Europe/Athens', // +02:00 - would make 2024-01-01T23:00:00Z appear as 2024-01-02T01:00:00 + order: [{ id: 'orders.localTimestamp', desc: false }], + }, + [ + { orders__count: 1 } + ], + { joinGraph, cubeEvaluator, compiler } + )); + + it('localTime dimension with day granularity returns local date', async () => dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [ + { + dimension: 'orders.createdAt', + granularity: 'day' + }, + { + dimension: 'orders.localTimestamp', + granularity: 'day' + } + ], + filters: [{ + member: 'orders.createdAt', + operator: 'equals', + values: ['2024-01-01'] + }], + timezone: 'Europe/Athens', // +02:00 + order: [{ id: 'orders.createdAt', desc: false }], + }, + [ + { + orders__count: 1, + orders__created_at_day: '2024-01-01T00:00:00.000Z', + orders__local_timestamp_day: '2024-01-01T00:00:00.000Z', // Local date, not Athens date + } + ], + { joinGraph, cubeEvaluator, compiler } + )); + + it('localTime dimension comparison shows it stays in local time while regular dimension converts', async () => dbRunner.runQueryTest( + { + measures: ['orders.count', 'orders.minLocalTimestamp'], + timeDimensions: [ + { + dimension: 'orders.createdAt', + granularity: 'day', + dateRange: ['2024-01-01', '2024-01-01'] + } + ], + timezone: 'Europe/Athens', // +02:00 + order: [{ id: 'orders.createdAt', desc: false }], + }, + [ + { + orders__count: 4, // Only 4 records on 2024-01-01 in Athens timezone + orders__created_at_day: '2024-01-01T00:00:00.000Z', + orders__min_local_timestamp: '2024-01-01T23:00:00.000Z', // Returns UTC representation of local time + } + ], + { joinGraph, cubeEvaluator, compiler } + )); +}); + diff --git a/packages/cubejs-schema-compiler/test/integration/mysql/mysql-local-time.test.ts b/packages/cubejs-schema-compiler/test/integration/mysql/mysql-local-time.test.ts new file mode 100644 index 0000000000000..c8e60b9c7d1ab --- /dev/null +++ b/packages/cubejs-schema-compiler/test/integration/mysql/mysql-local-time.test.ts @@ -0,0 +1,122 @@ +import { prepareYamlCompiler } from '../../unit/PrepareCompiler'; +import { MysqlQuery } from '../../../src/adapter/MysqlQuery'; +import { MySqlDbRunner } from './MySqlDbRunner'; + +describe('MySQL Local Time Dimensions', () => { + jest.setTimeout(200000); + + const dbRunner = new MySqlDbRunner(); + + const { compiler, joinGraph, cubeEvaluator } = prepareYamlCompiler( + ` + cubes: + - name: orders + sql: > + SELECT order_id, + created_at, + local_timestamp + FROM ( + SELECT @row := @row + 1 AS order_id, + DATE_ADD(TIMESTAMP('2024-01-01T00:00:00Z'), INTERVAL (@row * 6) HOUR) AS created_at, + DATE_ADD(TIMESTAMP('2024-01-01T23:00:00Z'), INTERVAL (@row * 6) HOUR) AS local_timestamp + FROM (SELECT 0 UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4) t1 + CROSS JOIN (SELECT @row := -1) r + ) AS subquery + + dimensions: + - name: order_id + sql: order_id + type: number + primary_key: true + + - name: createdAt + sql: created_at + type: time + + - name: localTimestamp + sql: local_timestamp + type: time + local_time: true + + measures: + - name: count + type: count + + - name: minLocalTimestamp + type: min + sql: local_timestamp + `, + MysqlQuery + ); + + it('localTime dimension filters on local time value ignoring query timezone', async () => dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.localTimestamp', + dateRange: ['2024-01-01', '2024-01-01'] + }], + timezone: 'Europe/Athens', // +02:00 - would make 2024-01-01T23:00:00Z appear as 2024-01-02T01:00:00 + order: [{ id: 'orders.localTimestamp', desc: false }], + }, + [ + { orders__count: '1' } + ], + { joinGraph, cubeEvaluator, compiler } + )); + + it('localTime dimension with day granularity returns local date', async () => dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [ + { + dimension: 'orders.createdAt', + granularity: 'day' + }, + { + dimension: 'orders.localTimestamp', + granularity: 'day' + } + ], + filters: [{ + member: 'orders.createdAt', + operator: 'equals', + values: ['2024-01-01'] + }], + timezone: 'Europe/Athens', // +02:00 + order: [{ id: 'orders.createdAt', desc: false }], + }, + [ + { + orders__count: '1', + orders__created_at_day: '2024-01-01T00:00:00.000', + orders__local_timestamp_day: '2024-01-01T00:00:00.000', // Local date, not Athens date + } + ], + { joinGraph, cubeEvaluator, compiler } + )); + + it('localTime dimension comparison shows it stays in local time while regular dimension converts', async () => dbRunner.runQueryTest( + { + measures: ['orders.count', 'orders.minLocalTimestamp'], + timeDimensions: [ + { + dimension: 'orders.createdAt', + granularity: 'day', + dateRange: ['2024-01-01', '2024-01-01'] + } + ], + timezone: 'Europe/Athens', // +02:00 + order: [{ id: 'orders.createdAt', desc: false }], + }, + [ + { + orders__count: '4', // Only 4 records on 2024-01-01 in Athens timezone + orders__created_at_day: '2024-01-01T00:00:00.000', + orders__min_local_timestamp: '2024-01-01T23:00:00.000', // Returns local time without timezone conversion + } + ], + { joinGraph, cubeEvaluator, compiler } + )); +}); + diff --git a/packages/cubejs-schema-compiler/test/integration/postgres/postgres-local-time.test.ts b/packages/cubejs-schema-compiler/test/integration/postgres/postgres-local-time.test.ts new file mode 100644 index 0000000000000..699677788793a --- /dev/null +++ b/packages/cubejs-schema-compiler/test/integration/postgres/postgres-local-time.test.ts @@ -0,0 +1,116 @@ +import { prepareYamlCompiler } from '../../unit/PrepareCompiler'; +import { PostgresQuery } from '../../../src/adapter/PostgresQuery'; +import { dbRunner } from './PostgresDBRunner'; + +describe('PostgreSQL Local Time Dimensions', () => { + jest.setTimeout(200000); + + const { compiler, joinGraph, cubeEvaluator } = prepareYamlCompiler( + ` + cubes: + - name: orders + sql: > + SELECT order_id, + created_at, + local_timestamp + FROM (SELECT GENERATE_SERIES(1, 5) AS order_id, + GENERATE_SERIES('2024-01-01 00:00:00'::timestamp, '2024-01-02 00:00:00'::timestamp, '6 hours') AS created_at, + GENERATE_SERIES('2024-01-01 23:00:00'::timestamp, '2024-01-02 23:00:00'::timestamp, '6 hours') AS local_timestamp) AS subquery + + dimensions: + - name: order_id + sql: order_id + type: number + primary_key: true + + - name: createdAt + sql: created_at + type: time + + - name: localTimestamp + sql: local_timestamp + type: time + local_time: true + + measures: + - name: count + type: count + + - name: minLocalTimestamp + type: min + sql: local_timestamp + `, + PostgresQuery + ); + + it('localTime dimension filters on local time value ignoring query timezone', async () => dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.localTimestamp', + dateRange: ['2024-01-01', '2024-01-01'] + }], + timezone: 'Europe/Athens', // +02:00 - would make 2024-01-01T23:00:00Z appear as 2024-01-02T01:00:00 + order: [{ id: 'orders.localTimestamp', desc: false }], + }, + [ + { orders__count: '1' } + ], + { joinGraph, cubeEvaluator, compiler } + )); + + it('localTime dimension with day granularity returns local date', async () => dbRunner.runQueryTest( + { + measures: ['orders.count'], + timeDimensions: [ + { + dimension: 'orders.createdAt', + granularity: 'day' + }, + { + dimension: 'orders.localTimestamp', + granularity: 'day' + } + ], + filters: [{ + member: 'orders.createdAt', + operator: 'equals', + values: ['2024-01-01'] + }], + timezone: 'Europe/Athens', // +02:00 + order: [{ id: 'orders.createdAt', desc: false }], + }, + [ + { + orders__count: '1', + orders__created_at_day: '2024-01-01T00:00:00.000Z', + orders__local_timestamp_day: '2024-01-01T00:00:00.000Z', // Local date, not Athens date + } + ], + { joinGraph, cubeEvaluator, compiler } + )); + + it('localTime dimension comparison shows it stays in local time while regular dimension converts', async () => dbRunner.runQueryTest( + { + measures: ['orders.count', 'orders.minLocalTimestamp'], + timeDimensions: [ + { + dimension: 'orders.createdAt', + granularity: 'day', + dateRange: ['2024-01-01', '2024-01-01'] + } + ], + timezone: 'Europe/Athens', // +02:00 + order: [{ id: 'orders.createdAt', desc: false }], + }, + [ + { + orders__count: '4', // Only 4 records on 2024-01-01 in Athens timezone + orders__created_at_day: '2024-01-01T00:00:00.000Z', + orders__min_local_timestamp: '2024-01-01T23:00:00.000Z', // Returns UTC representation of local time + } + ], + { joinGraph, cubeEvaluator, compiler } + )); +}); + diff --git a/packages/cubejs-schema-compiler/test/unit/__snapshots__/error-reporter.test.ts.snap b/packages/cubejs-schema-compiler/test/unit/__snapshots__/error-reporter.test.ts.snap index 3f4aa0aa99e85..2f4515e9aaacc 100644 --- a/packages/cubejs-schema-compiler/test/unit/__snapshots__/error-reporter.test.ts.snap +++ b/packages/cubejs-schema-compiler/test/unit/__snapshots__/error-reporter.test.ts.snap @@ -6,8 +6,8 @@ Object { Object { "fileName": "test.js", "message": "Syntax Error: Duplicate error -> 1 | test content -  | ^^^ Duplicate error", +> 1 | test content + | ^^^ Duplicate error", "plainMessage": "Syntax Error: Duplicate error", }, ], @@ -39,13 +39,13 @@ Validation error schema/users.js Errors: Syntax Error: Invalid measure definition -  2 | sql: \`SELECT * FROM users\`, -  3 | measures: { -> 4 | count: { -  | ^^^^^ Invalid measure definition -  5 | type: 'count' -  6 | } -  7 | } + 2 | sql: \`SELECT * FROM users\`, + 3 | measures: { +> 4 | count: { + | ^^^^^ Invalid measure definition + 5 | type: 'count' + 6 | } + 7 | } " `; @@ -59,12 +59,12 @@ exports[`ErrorReporter should group and format errors and warnings from differen Array [ "schema/users.js: Warning: Deprecated syntax -  1 | cube('Users', { -> 2 | sql: \`SELECT * FROM users\`, -  | ^^^ Deprecated syntax -  3 | measures: { -  4 | count: { -  5 | type: 'count'", + 1 | cube('Users', { +> 2 | sql: \`SELECT * FROM users\`, + | ^^^ Deprecated syntax + 3 | measures: { + 4 | count: { + 5 | type: 'count'", "schema/orders.js: Consider adding indexes", "schema/analytics.js: diff --git a/packages/cubejs-schema-compiler/test/unit/base-query.test.ts b/packages/cubejs-schema-compiler/test/unit/base-query.test.ts index 26bf4782b1558..8198fe226fe28 100644 --- a/packages/cubejs-schema-compiler/test/unit/base-query.test.ts +++ b/packages/cubejs-schema-compiler/test/unit/base-query.test.ts @@ -1012,7 +1012,7 @@ describe('SQL Generation', () => { relationship: 'one_to_one' }, }` - }).replace(`sql: \`\${CUBE}.location = 'San Francisco'\``, `sql: \`\${FILTER_PARAMS.cardsA.location.filter('location')}\``), + }).replace('sql: `${CUBE}.location = \'San Francisco\'`', 'sql: `${FILTER_PARAMS.cardsA.location.filter(\'location\')}`'), createCubeSchema({ name: 'cardsB', sqlTable: 'card2_tbl', 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..7aa6b9ba7b791 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,98 @@ describe('Cube Validation', () => { expect(result.error).toBeTruthy(); }); }); + + describe('localTime validation', () => { + it('time dimension with localTime - correct', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'orders', + sql: () => 'SELECT * FROM orders', + dimensions: { + local_date: { + sql: () => 'local_date', + type: 'time', + localTime: true + } + }, + fileName: 'fileName', + }; + + const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter()); + expect(validationResult.error).toBeFalsy(); + }); + + it('string dimension with localTime - should fail', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'orders', + sql: () => 'SELECT * FROM orders', + dimensions: { + local_date: { + sql: () => 'local_date', + type: 'string', + localTime: true + } + }, + fileName: 'fileName', + }; + + const validationResult = cubeValidator.validate(cube, { + error: (message: any, _e: any) => { + expect(message).toContain('localTime'); + } + } as any); + + expect(validationResult.error).toBeTruthy(); + }); + + it('number dimension with localTime - should fail', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'orders', + sql: () => 'SELECT * FROM orders', + dimensions: { + amount: { + sql: () => 'amount', + type: 'number', + localTime: true + } + }, + fileName: 'fileName', + }; + + const validationResult = cubeValidator.validate(cube, { + error: (message: any, _e: any) => { + expect(message).toContain('localTime'); + } + } as any); + + expect(validationResult.error).toBeTruthy(); + }); + + it('time dimension with localTime and granularities - correct', async () => { + const cubeValidator = new CubeValidator(new CubeSymbols()); + const cube = { + name: 'orders', + sql: () => 'SELECT * FROM orders', + dimensions: { + local_date: { + sql: () => 'local_date', + type: 'time', + localTime: true, + granularities: { + fiscal_year: { + interval: '1 year', + origin: '2024-04-01' + } + } + } + }, + fileName: 'fileName', + }; + + const validationResult = cubeValidator.validate(cube, new ConsoleErrorReporter()); + expect(validationResult.error).toBeFalsy(); + }); + }); }); diff --git a/packages/cubejs-schema-compiler/test/unit/local-time-dimensions.test.ts b/packages/cubejs-schema-compiler/test/unit/local-time-dimensions.test.ts new file mode 100644 index 0000000000000..b225934aa8a4c --- /dev/null +++ b/packages/cubejs-schema-compiler/test/unit/local-time-dimensions.test.ts @@ -0,0 +1,472 @@ +/* eslint-disable no-restricted-syntax */ +import { PostgresQuery } from '../../src/adapter/PostgresQuery'; +import { prepareJsCompiler } from './PrepareCompiler'; + +describe('Local Time Dimensions', () => { + const { compiler, joinGraph, cubeEvaluator } = prepareJsCompiler(` + cube(\`orders\`, { + sql: \` + SELECT * FROM orders + \`, + + measures: { + count: { + type: 'count' + } + }, + + dimensions: { + created_at: { + type: 'time', + sql: 'created_at' + }, + + local_date: { + type: 'time', + sql: \`DATE(created_at AT TIME ZONE 'America/Los_Angeles')\`, + localTime: true, + description: 'Date in Pacific timezone' + }, + + local_hour: { + type: 'time', + sql: \`DATE_TRUNC('hour', created_at AT TIME ZONE 'America/Los_Angeles')\`, + localTime: true, + description: 'Hour in Pacific timezone' + } + } + }) + `); + + describe('SQL generation', () => { + it('should not apply convertTz to local time dimensions', async () => { + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.local_date', + granularity: 'day', + dateRange: ['2025-01-01', '2025-01-31'] + }], + timezone: 'America/New_York' + }); + + const queryAndParams = query.buildSqlAndParams(); + const sql = queryAndParams[0]; + + // Should not contain timezone conversion for local_date + // The SQL should use the raw dimension SQL without AT TIME ZONE wrapping + expect(sql).toContain('DATE(created_at AT TIME ZONE \'America/Los_Angeles\')'); + + // Should not double-convert (shouldn't have AT TIME ZONE 'America/New_York' on local_date) + const localDateMatches = sql.match(/DATE\(created_at AT TIME ZONE 'America\/Los_Angeles'\)/g); + expect(localDateMatches).toBeTruthy(); + }); + + it('should apply convertTz to regular time dimensions', async () => { + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.created_at', + granularity: 'day', + dateRange: ['2025-01-01', '2025-01-31'] + }], + timezone: 'America/New_York' + }); + + const queryAndParams = query.buildSqlAndParams(); + const sql = queryAndParams[0]; + + // Regular time dimension should have timezone conversion + expect(sql).toContain('AT TIME ZONE \'America/New_York\''); + }); + + it('should support dateRange on local time dimensions', async () => { + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.local_date', + dateRange: ['2025-01-01', '2025-01-31'] + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + const sql = queryAndParams[0]; + + // Should have WHERE clause with date range filter + expect(sql).toMatch(/WHERE/i); + // For localTime dimensions, parameters should NOT have timezone suffix + expect(queryAndParams[1]).toContain('2025-01-01T00:00:00.000'); + expect(queryAndParams[1]).toContain('2025-01-31T23:59:59.999'); + }); + + it('should support granularity on local time dimensions', async () => { + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.local_hour', + granularity: 'hour', + dateRange: ['2025-01-01', '2025-01-02'] + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + const sql = queryAndParams[0]; + + // Should apply granularity grouping + expect(sql).toMatch(/DATE_TRUNC\('hour'/i); + }); + + it('should work with both local and regular time dimensions in same query', async () => { + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + dimensions: ['orders.local_date'], + timeDimensions: [{ + dimension: 'orders.created_at', + granularity: 'day', + dateRange: ['2025-01-01', '2025-01-31'] + }], + timezone: 'America/New_York' + }); + + const queryAndParams = query.buildSqlAndParams(); + const sql = queryAndParams[0]; + + // local_date should not have New York timezone conversion + expect(sql).toContain('DATE(created_at AT TIME ZONE \'America/Los_Angeles\')'); + + // created_at should have New York timezone conversion + expect(sql).toContain('AT TIME ZONE \'America/New_York\''); + }); + }); + + describe('Query features', () => { + it('should support "last month" dateRange on local time dimensions', async () => { + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.local_date', + dateRange: 'last month', + granularity: 'day' + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + const sql = queryAndParams[0]; + + // Should have date filtering + expect(sql).toMatch(/WHERE/i); + // Should have grouping by day + expect(sql).toMatch(/GROUP BY/i); + }); + + it('should support multiple granularities on same local time dimension', async () => { + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + timeDimensions: [ + { + dimension: 'orders.local_date', + granularity: 'day', + dateRange: ['2025-01-01', '2025-01-31'] + }, + { + dimension: 'orders.local_hour', + granularity: 'hour', + dateRange: ['2025-01-01', '2025-01-31'] + } + ], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + const sql = queryAndParams[0]; + + // Both dimensions should be in the query + expect(sql).toContain('DATE(created_at AT TIME ZONE \'America/Los_Angeles\')'); + expect(sql).toContain('DATE_TRUNC(\'hour\', created_at AT TIME ZONE \'America/Los_Angeles\')'); + }); + }); + + describe('Edge cases', () => { + it('should handle local time dimensions without granularity', async () => { + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + dimensions: ['orders.local_date'], + measures: ['orders.count'], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + const sql = queryAndParams[0]; + + // Should include the dimension without timezone conversion + expect(sql).toContain('DATE(created_at AT TIME ZONE \'America/Los_Angeles\')'); + }); + + it('should handle local time dimensions in filters', async () => { + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + filters: [{ + member: 'orders.local_date', + operator: 'inDateRange', + values: ['2025-01-01', '2025-01-31'] + }], + timezone: 'America/New_York' + }); + + const queryAndParams = query.buildSqlAndParams(); + const sql = queryAndParams[0]; + + // Should filter on the local date dimension + expect(sql).toMatch(/WHERE/i); + expect(sql).toContain('DATE(created_at AT TIME ZONE \'America/Los_Angeles\')'); + }); + }); + + describe('ISO 8601 Timezone Suffix Stripping', () => { + it('strips Z (UTC) suffix', async () => { + const { compiler, joinGraph, cubeEvaluator } = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT * FROM orders\`, + measures: { + count: { type: 'count' } + }, + dimensions: { + createdAt: { + sql: 'created_at', + type: 'time', + localTime: true + } + } + }) + `); + + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + dateRange: ['2025-01-01T00:00:00.000Z', '2025-01-31T23:59:59.999Z'] + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + const params = queryAndParams[1]; + + // Parameters should not have Z suffix for local time + expect(params[0]).not.toContain('Z'); + expect(params[1]).not.toContain('Z'); + expect(params[0]).toBe('2025-01-01T00:00:00.000'); + expect(params[1]).toBe('2025-01-31T23:59:59.999'); + }); + + it('strips +hh:mm offset format', async () => { + const { compiler, joinGraph, cubeEvaluator } = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT * FROM orders\`, + measures: { + count: { type: 'count' } + }, + dimensions: { + createdAt: { + sql: 'created_at', + type: 'time', + localTime: true + } + } + }) + `); + + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + dateRange: ['2025-01-01T00:00:00.000+05:30', '2025-01-31T23:59:59.999+05:30'] + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + const params = queryAndParams[1]; + + // Parameters should not have +05:30 offset for local time + expect(params[0]).not.toContain('+05:30'); + expect(params[1]).not.toContain('+05:30'); + expect(params[0]).toBe('2025-01-01T00:00:00.000'); + expect(params[1]).toBe('2025-01-31T23:59:59.999'); + }); + + it('strips -hh:mm offset format', async () => { + const { compiler, joinGraph, cubeEvaluator } = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT * FROM orders\`, + measures: { + count: { type: 'count' } + }, + dimensions: { + createdAt: { + sql: 'created_at', + type: 'time', + localTime: true + } + } + }) + `); + + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + dateRange: ['2025-01-01T00:00:00.000-08:00', '2025-01-31T23:59:59.999-08:00'] + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + const params = queryAndParams[1]; + + // Parameters should not have -08:00 offset for local time + expect(params[0]).not.toContain('-08:00'); + expect(params[1]).not.toContain('-08:00'); + expect(params[0]).toBe('2025-01-01T00:00:00.000'); + expect(params[1]).toBe('2025-01-31T23:59:59.999'); + }); + + it('strips +hhmm compact offset format', async () => { + const { compiler, joinGraph, cubeEvaluator } = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT * FROM orders\`, + measures: { + count: { type: 'count' } + }, + dimensions: { + createdAt: { + sql: 'created_at', + type: 'time', + localTime: true + } + } + }) + `); + + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + dateRange: ['2025-01-01T00:00:00.000+0530', '2025-01-31T23:59:59.999+0530'] + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + const params = queryAndParams[1]; + + // Parameters should not have +0530 offset for local time + expect(params[0]).not.toContain('+0530'); + expect(params[1]).not.toContain('+0530'); + expect(params[0]).toBe('2025-01-01T00:00:00.000'); + expect(params[1]).toBe('2025-01-31T23:59:59.999'); + }); + + it('strips +hh hour-only offset format', async () => { + const { compiler, joinGraph, cubeEvaluator } = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT * FROM orders\`, + measures: { + count: { type: 'count' } + }, + dimensions: { + createdAt: { + sql: 'created_at', + type: 'time', + localTime: true + } + } + }) + `); + + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + dateRange: ['2025-01-01T00:00:00.000+05', '2025-01-31T23:59:59.999+05'] + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + const params = queryAndParams[1]; + + // Parameters should not have +05 offset for local time + expect(params[0]).not.toContain('+05'); + expect(params[1]).not.toContain('+05'); + expect(params[0]).toBe('2025-01-01T00:00:00.000'); + expect(params[1]).toBe('2025-01-31T23:59:59.999'); + }); + + it('handles dates without timezone suffixes unchanged', async () => { + const { compiler, joinGraph, cubeEvaluator } = prepareJsCompiler(` + cube(\`orders\`, { + sql: \`SELECT * FROM orders\`, + measures: { + count: { type: 'count' } + }, + dimensions: { + createdAt: { + sql: 'created_at', + type: 'time', + localTime: true + } + } + }) + `); + + await compiler.compile(); + + const query = new PostgresQuery({ joinGraph, cubeEvaluator, compiler }, { + measures: ['orders.count'], + timeDimensions: [{ + dimension: 'orders.createdAt', + dateRange: ['2025-01-01T00:00:00.000', '2025-01-31T23:59:59.999'] + }], + timezone: 'UTC' + }); + + const queryAndParams = query.buildSqlAndParams(); + const params = queryAndParams[1]; + + // Parameters should remain unchanged + expect(params[0]).toBe('2025-01-01T00:00:00.000'); + expect(params[1]).toBe('2025-01-31T23:59:59.999'); + }); + }); +}); diff --git a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs index 2f25a0d42d351..e89d59bcb869e 100644 --- a/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs +++ b/rust/cubesqlplanner/cubesqlplanner/src/cube_bridge/dimension_definition.rs @@ -30,6 +30,8 @@ pub struct DimensionDefinitionStatic { pub values: Option>, #[serde(rename = "primaryKey")] pub primary_key: Option, + #[serde(rename = "localTime")] + pub local_time: Option, } #[nativebridge::native_bridge(DimensionDefinitionStatic)]