Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,12 @@ date range. The values must be an array of timestamps in the [ISO 8601][wiki-iso
format, for example `2025-01-02` or `2025-01-02T03:04:05.067Z`. If only one timestamp
is specified, the filter would be set exactly to this timestamp.

You may also pass a single-element array containing a [relative date range
string][ref-relative-date-range] (for example, `["last 2 weeks"]`), using the
same formats supported by `timeDimensions.dateRange`. Cube resolves the string
to an absolute `[start, end]` range using the query timezone before generating
SQL. This applies to every date operator on this page.

There is a convenient way to use date filters with grouping -
[learn more about the `timeDimensions` property here](#time-dimensions-format)

Expand All @@ -399,6 +405,14 @@ There is a convenient way to use date filters with grouping -
}
```

```json
{
"member": "posts.time",
"operator": "inDateRange",
"values": ["last 2 weeks"]
}
```

### `notInDateRange`

<Warning>
Expand Down Expand Up @@ -584,6 +598,57 @@ are translated to expressions in `WHERE` and `HAVING` clauses, respectively.
In other words, dimension filters apply to raw (unaggregated) data and measure filters
apply to aggregated data, so it's not possible to express such filters in SQL semantics.

### Date filters in logical operators

Date operators (`inDateRange`, `notInDateRange`, `beforeDate`, `beforeOrOnDate`,
`afterDate`, `afterOrOnDate`, `onTheDate`) can be placed inside `or`/`and`
logical operators. The date predicate is emitted inside the corresponding
logical group, so you can express date constraints that apply to only one
branch of an `or`:

```json
{
"filters": [{
"or": [
{
"member": "Orders.createdAt",
"operator": "inDateRange",
"values": ["last 2 weeks"]
},
{
"member": "Orders.status",
"operator": "equals",
"values": ["pending"]
}
]
}]
}
```

The resulting SQL has the date predicate inside the `OR`, not as a global `AND`:

```sql
WHERE (created_at >= ... AND created_at <= ...)
OR (status = 'pending')
```

<Warning>

This differs from [`timeDimensions.dateRange`](#time-dimensions-format), which
is always emitted as a global `AND` at the top of the `WHERE` clause and also
drives granularity and pre-aggregation matching. Use a `filters` entry when
you need the date constraint scoped to one branch of an `or`/`and` group;
use `timeDimensions.dateRange` when you need a global date constraint that
can match pre-aggregations.

If you set both `timeDimensions.dateRange` and a date filter in `filters` on
the same member, both predicates are emitted and `AND`-ed together — the
`timeDimensions` range constrains the entire query, including every branch
of any `or` group. This is rarely the intended behavior; prefer one path
or the other.

</Warning>

## Time Dimensions Format

Since grouping and filtering by a time dimension is quite a common case, Cube
Expand Down Expand Up @@ -674,6 +739,11 @@ refer to its documentation for more examples.

</Info>

The same relative-date strings are accepted in the `values` array of date
filter operators (`inDateRange`, `notInDateRange`, `beforeDate`, etc.) when
the array contains a single string. See [`inDateRange`](#indaterange) and
[Date filters in logical operators](#date-filters-in-logical-operators).

[ref-client-core-resultset-drilldown]: /reference/javascript-sdk/reference/cubejs-client-core#drilldown
[ref-schema-ref-preaggs-refreshkey]: /reference/data-modeling/pre-aggregations#refresh_key
[ref-schema-ref-preaggs-refreshkey-every]: /reference/data-modeling/pre-aggregations#every
Expand Down
116 changes: 97 additions & 19 deletions packages/cubejs-api-gateway/src/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,15 +244,106 @@ export const preAggsJobsRequestSchema = Joi.object({

const DateRegex = /^\d\d\d\d-\d\d-\d\d$/;

const normalizeQueryFilters = (filter) => (
const DATE_RANGE_OPERATORS = ['inDateRange', 'notInDateRange'];
// Mirrors Tesseract's date_single.rs boundary semantics:
// Before → < start, AfterOrOn → >= start
const START_DATE_OPERATORS = ['beforeDate', 'afterOrOnDate'];
// BeforeOrOn → <= end, After → > end
const END_DATE_OPERATORS = ['beforeOrOnDate', 'afterDate'];

// Absolute values must pass through byte-exact: bare dates keep each planner's
// own day-boundary handling, timestamps keep their time component
const AbsoluteDateTimeRegex = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?)?$/;

// Resolve a dateRange input — a relative string ("last 2 weeks"), a single
// absolute date, or a 2-element array — to a normalized [startISO, endISO]
// pair.
export const resolveDateRange = (input, timezone) => {
let dateRange;
if (typeof input === 'string') {
dateRange = dateParser(input, timezone);
} else if (Array.isArray(input)) {
dateRange = input.length === 1 ? [input[0], input[0]] : input;
} else {
return input;
}

return dateRange && dateRange.map(
(d, i) => (
i === 0 ?
moment.utc(d).format(d.match(DateRegex) ? 'YYYY-MM-DDT00:00:00.000' : moment.HTML5_FMT.DATETIME_LOCAL_MS) :
moment.utc(d).format(d.match(DateRegex) ? 'YYYY-MM-DDT23:59:59.999' : moment.HTML5_FMT.DATETIME_LOCAL_MS)
)
);
};

// Resolve relative date strings inside a filter leaf's `values` so that
// date-range filters can appear inside OR/AND groups (and at the top level)
// with the same relative-date support that `timeDimensions.dateRange` has.
// Reuses resolveDateRange so both paths produce identical output. Non-date
// operators and already-absolute values pass through unchanged.
export const normalizeDateFilterValues = (filter, timezone) => {
if (!filter || !filter.operator || !Array.isArray(filter.values)) {
return filter;
}

// Fail fast at the gateway if a range operator is given a multi-element
// `values` array that contains a relative-date string. This shape falls
// through the resolver (which only handles single-element values) and
// would otherwise error deep in query execution with an opaque message. This
// surfaces the failure at the API boundary instead.
if (
(DATE_RANGE_OPERATORS.includes(filter.operator) || filter.operator === 'onTheDate') &&
filter.values.length > 1 &&
filter.values.some(v => typeof v === 'string' && !AbsoluteDateTimeRegex.test(v))
) {
throw new UserError(
`Relative-date strings are only supported when \`values\` has a single element for operator \`${filter.operator}\`. Pass an absolute two-element [start, end] pair, or a single relative string like ["last 2 weeks"]. Got: ${JSON.stringify(filter.values)}`
);
}

if (filter.values.length !== 1) {
return filter;
}

const value = filter.values[0];
if (typeof value !== 'string') {
return filter;
}

if (DATE_RANGE_OPERATORS.includes(filter.operator) || filter.operator === 'onTheDate') {
// onTheDate resolves to a two-sided range: legacy onTheDateWhere reads
// values[0]/values[1], and Tesseract maps onTheDate to InDateRange which
// requires exactly 2 values.
return { ...filter, values: resolveDateRange(value, timezone) };
}

if (AbsoluteDateTimeRegex.test(value)) {
return filter;
}

if (START_DATE_OPERATORS.includes(filter.operator)) {
const [start] = resolveDateRange(value, timezone);
return { ...filter, values: [start] };
}

if (END_DATE_OPERATORS.includes(filter.operator)) {
const [, end] = resolveDateRange(value, timezone);
return { ...filter, values: [end] };
}

return filter;
};

const normalizeQueryFilters = (filter, timezone) => (
filter.map(f => {
const res = { ...f };
if (f.or) {
res.or = normalizeQueryFilters(f.or);
res.or = normalizeQueryFilters(f.or, timezone);
return res;
}
if (f.and) {
res.and = normalizeQueryFilters(f.and);
res.and = normalizeQueryFilters(f.and, timezone);
return res;
}

Expand All @@ -277,7 +368,7 @@ const normalizeQueryFilters = (filter) => (
delete res.dimension;
}

return res;
return normalizeDateFilterValues(res, timezone);
})
);

Expand Down Expand Up @@ -377,27 +468,14 @@ const normalizeQuery = (query, persistent, cacheMode) => {
...(query.order ? { order: normalizeQueryOrder(query.order) } : {}),
limit: newLimit,
timezone,
filters: normalizeQueryFilters(query.filters || []),
filters: normalizeQueryFilters(query.filters || [], timezone),
dimensions: (query.dimensions || []).filter(d => typeof d !== 'string' || d.split('.').length !== 3),
timeDimensions: (query.timeDimensions || []).map(td => {
let dateRange;

const compareDateRange = td.compareDateRange ? td.compareDateRange.map((currentDateRange) => (typeof currentDateRange === 'string' ? dateParser(currentDateRange, timezone) : currentDateRange)) : null;

if (typeof td.dateRange === 'string') {
dateRange = dateParser(td.dateRange, timezone);
} else {
dateRange = td.dateRange && td.dateRange.length === 1 ? [td.dateRange[0], td.dateRange[0]] : td.dateRange;
}
return {
...td,
dateRange: dateRange && dateRange.map(
(d, i) => (
i === 0 ?
moment.utc(d).format(d.match(DateRegex) ? 'YYYY-MM-DDT00:00:00.000' : moment.HTML5_FMT.DATETIME_LOCAL_MS) :
moment.utc(d).format(d.match(DateRegex) ? 'YYYY-MM-DDT23:59:59.999' : moment.HTML5_FMT.DATETIME_LOCAL_MS)
)
),
dateRange: resolveDateRange(td.dateRange, timezone),
...(compareDateRange ? { compareDateRange } : {})
};
}).concat(regularToTimeDimension)
Expand Down
Loading
Loading