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
-[0m[31m[1m>[22m[39m[90m 1 |[39m test content
- [90m |[39m [31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m [31m[1mDuplicate error[22m[39m[0m",
+> 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
-[0m [90m 2 |[39m sql[33m:[39m [32m\`SELECT * FROM users\`[39m[33m,[39m
- [90m 3 |[39m measures[33m:[39m {
-[31m[1m>[22m[39m[90m 4 |[39m count[33m:[39m {
- [90m |[39m [31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m [31m[1mInvalid measure definition[22m[39m
- [90m 5 |[39m type[33m:[39m [32m'count'[39m
- [90m 6 |[39m }
- [90m 7 |[39m }[0m
+ 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
-[0m [90m 1 |[39m cube([32m'Users'[39m[33m,[39m {
-[31m[1m>[22m[39m[90m 2 |[39m sql[33m:[39m [32m\`SELECT * FROM users\`[39m[33m,[39m
- [90m |[39m [31m[1m^[22m[39m[31m[1m^[22m[39m[31m[1m^[22m[39m [31m[1mDeprecated syntax[22m[39m
- [90m 3 |[39m measures[33m:[39m {
- [90m 4 |[39m count[33m:[39m {
- [90m 5 |[39m type[33m:[39m [32m'count'[39m[0m",
+ 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)]