diff --git a/docs-mintlify/reference/core-data-apis/rest-api/query-format.mdx b/docs-mintlify/reference/core-data-apis/rest-api/query-format.mdx index b881a7dc8f37d..987211d938cd8 100644 --- a/docs-mintlify/reference/core-data-apis/rest-api/query-format.mdx +++ b/docs-mintlify/reference/core-data-apis/rest-api/query-format.mdx @@ -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) @@ -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` @@ -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') +``` + + + +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. + + + ## Time Dimensions Format Since grouping and filtering by a time dimension is quite a common case, Cube @@ -674,6 +739,11 @@ refer to its documentation for more examples. +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 diff --git a/packages/cubejs-api-gateway/src/query.js b/packages/cubejs-api-gateway/src/query.js index 88d5132648848..a3dda0f78837d 100644 --- a/packages/cubejs-api-gateway/src/query.js +++ b/packages/cubejs-api-gateway/src/query.js @@ -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; } @@ -277,7 +368,7 @@ const normalizeQueryFilters = (filter) => ( delete res.dimension; } - return res; + return normalizeDateFilterValues(res, timezone); }) ); @@ -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) diff --git a/packages/cubejs-api-gateway/test/normalize-query-filters-dates.test.js b/packages/cubejs-api-gateway/test/normalize-query-filters-dates.test.js new file mode 100644 index 0000000000000..a32fe2693477b --- /dev/null +++ b/packages/cubejs-api-gateway/test/normalize-query-filters-dates.test.js @@ -0,0 +1,427 @@ +/* globals describe,test,expect,jest,beforeEach,afterEach */ + +import { normalizeQuery, normalizeDateFilterValues, resolveDateRange } from '../src/query'; + +// Tests for filter-leaf date-range resolution at the gateway. This mirrors +// what `timeDimensions.dateRange` has always done: a single relative string +// (e.g. "last 2 weeks") resolves to an absolute [start, end] pair before the +// schema compiler sees it. The point is reuse — same resolver, same output — +// so an OR group with a relative date filter behaves identically to a query +// using top-level timeDimensions. + +const baseQuery = { + measures: ['Orders.count'], + timezone: 'UTC', +}; + +const FIXED_NOW = new Date(Date.UTC(2026, 5, 25, 13, 0, 0, 0)); + +describe('normalizeDateFilterValues', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(FIXED_NOW.getTime()); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('inDateRange with relative string resolves to absolute [start, end] pair', () => { + // Why: this is the core invariant — relative strings must be resolved + // before reaching BaseFilter, which has no relative-date logic of its own. + const result = normalizeDateFilterValues( + { member: 'Orders.createdAt', operator: 'inDateRange', values: ['last 2 weeks'] }, + 'UTC' + ); + + expect(result.values).toHaveLength(2); + expect(result.values[0]).toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00\.000$/); + expect(result.values[1]).toMatch(/^\d{4}-\d{2}-\d{2}T23:59:59\.999$/); + }); + + test('notInDateRange resolves the same way as inDateRange', () => { + // Why: both range operators must accept relative strings — a user + // filtering "NOT in last 2 weeks" expects the same boundary handling. + const result = normalizeDateFilterValues( + { member: 'Orders.createdAt', operator: 'notInDateRange', values: ['last 2 weeks'] }, + 'UTC' + ); + + expect(result.values).toHaveLength(2); + }); + + test('inDateRange with absolute two-element array is passed through unchanged', () => { + // Why: absolute date pairs are already in the form BaseFilter expects. + // Mutating them would risk timezone drift or silent reformatting that + // differs from today's behavior for callers passing absolute ranges. + const filter = { + member: 'Orders.createdAt', + operator: 'inDateRange', + values: ['2026-01-01', '2026-01-31'], + }; + + const result = normalizeDateFilterValues(filter, 'UTC'); + + expect(result.values).toEqual(['2026-01-01', '2026-01-31']); + }); + + test('beforeDate with relative string resolves to start-of-day', () => { + // Why: mirrors Tesseract's date_single.rs — `beforeDate` compares + // `< startOfDay`. Using end-of-day here would include the whole day + // instead of excluding it. + const result = normalizeDateFilterValues( + { member: 'Orders.createdAt', operator: 'beforeDate', values: ['yesterday'] }, + 'UTC' + ); + + expect(result.values).toHaveLength(1); + expect(result.values[0]).toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00\.000$/); + }); + + test('afterOrOnDate with relative string resolves to start-of-day', () => { + // Why: mirrors Tesseract's `>= startOfDay` — the operator's inclusive + // lower boundary must land at the start of the target day. + const result = normalizeDateFilterValues( + { member: 'Orders.createdAt', operator: 'afterOrOnDate', values: ['yesterday'] }, + 'UTC' + ); + + expect(result.values).toHaveLength(1); + expect(result.values[0]).toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00\.000$/); + }); + + test('beforeOrOnDate with relative string resolves to end-of-day', () => { + // Why: mirrors Tesseract's `<= endOfDay`. Using start-of-day here (as + // the pre-fix code did) would exclude almost the entire target day. + const result = normalizeDateFilterValues( + { member: 'Orders.createdAt', operator: 'beforeOrOnDate', values: ['yesterday'] }, + 'UTC' + ); + + expect(result.values).toHaveLength(1); + expect(result.values[0]).toMatch(/^\d{4}-\d{2}-\d{2}T23:59:59\.999$/); + }); + + test('afterDate with relative string resolves to end-of-day', () => { + // Why: mirrors Tesseract's `> endOfDay`. Using start-of-day would let + // "after yesterday" still match rows from within yesterday. + const result = normalizeDateFilterValues( + { member: 'Orders.createdAt', operator: 'afterDate', values: ['yesterday'] }, + 'UTC' + ); + + expect(result.values).toHaveLength(1); + expect(result.values[0]).toMatch(/^\d{4}-\d{2}-\d{2}T23:59:59\.999$/); + }); + + test('onTheDate with relative string resolves to a two-value range', () => { + // Why: onTheDate is broken in both planners today with a single value — + // legacy `onTheDateWhere` reads values[0]/values[1] and Tesseract maps + // onTheDate to InDateRange which strictly requires 2 args. Resolving here + // to [start, end] fixes the operator in both planners simultaneously. + const result = normalizeDateFilterValues( + { member: 'Orders.createdAt', operator: 'onTheDate', values: ['yesterday'] }, + 'UTC' + ); + + expect(result.values).toHaveLength(2); + expect(result.values[0]).toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00\.000$/); + expect(result.values[1]).toMatch(/^\d{4}-\d{2}-\d{2}T23:59:59\.999$/); + }); + + test('single-date operator with absolute bare date is passed through byte-exact', () => { + // Why: pre-PR the gateway didn't touch filter values, so `beforeOrOnDate: + // ["2024-01-15"]` reached Tesseract's format_to_date which padded it to + // end-of-day. Resolving here would rewrite it to T00:00:00.000, silently + // excluding all of Jan 15 — a data-correctness regression under the + // default planner. Absolute strings must round-trip unchanged. + const filter = { + member: 'Orders.createdAt', + operator: 'beforeOrOnDate', + values: ['2024-01-15'], + }; + + const result = normalizeDateFilterValues(filter, 'UTC'); + + expect(result.values).toEqual(['2024-01-15']); + }); + + test('single-date operator with absolute timestamp is passed through byte-exact', () => { + // Why: dateParser truncates every string to day granularity via chrono's + // startOf('day'). A user passing "2024-01-15T10:30:00.000" would silently + // lose the time component under the pre-fix path. Absolute timestamps + // must not be re-parsed. + const filter = { + member: 'Orders.createdAt', + operator: 'afterDate', + values: ['2024-01-15T10:30:00.000'], + }; + + const result = normalizeDateFilterValues(filter, 'UTC'); + + expect(result.values).toEqual(['2024-01-15T10:30:00.000']); + }); + + test('non-string value is passed through unchanged', () => { + // Why: numeric or boolean values in `values` cannot be date strings, so + // dispatching them into dateParser would raise an opaque error. The + // typeof guard is what keeps the helper safe for those callers. + const filter = { member: 'Orders.count', operator: 'gt', values: [10] }; + + const result = normalizeDateFilterValues(filter, 'UTC'); + + expect(result).toBe(filter); + }); + + test('non-date operator (equals) is returned unchanged', () => { + // Why: the helper must not touch unrelated filters. + const filter = { member: 'Orders.status', operator: 'equals', values: ['active'] }; + + const result = normalizeDateFilterValues(filter, 'UTC'); + + expect(result).toBe(filter); + }); + + test('group filter (no operator) is returned unchanged', () => { + // Why: the walker that wires this helper in recurses into OR/AND groups + // itself; the helper must be a safe no-op on group nodes. + const groupFilter = { or: [{ member: 'X', operator: 'equals', values: ['1'] }] }; + + const result = normalizeDateFilterValues(groupFilter, 'UTC'); + + expect(result).toBe(groupFilter); + }); + + test('inDateRange with two relative strings raises UserError at the boundary', () => { + // Why: pre-fix, `values: ["last week", "this week"]` bypassed the + // resolver (which only handles single-element values) and failed deep in + // query execution with an opaque error. Users should get a clear API- + // boundary error explaining the supported shapes. + expect(() => normalizeDateFilterValues( + { member: 'Orders.createdAt', operator: 'inDateRange', values: ['last week', 'this week'] }, + 'UTC' + )).toThrow(/Relative-date strings are only supported/); + }); + + test('inDateRange with mixed relative + absolute strings raises UserError', () => { + // Why: even one relative element in a multi-value array is unsupported — + // the resolver has no way to reconcile a relative range with an absolute + // endpoint. Reject at the boundary. + expect(() => normalizeDateFilterValues( + { member: 'Orders.createdAt', operator: 'inDateRange', values: ['last week', '2024-01-31'] }, + 'UTC' + )).toThrow(/Relative-date strings are only supported/); + }); + + test('notInDateRange with two relative strings raises UserError', () => { + // Why: parity — both range operators must fail the same way for the + // same shape. + expect(() => normalizeDateFilterValues( + { member: 'Orders.createdAt', operator: 'notInDateRange', values: ['last week', 'this week'] }, + 'UTC' + )).toThrow(/Relative-date strings are only supported/); + }); + + test('inDateRange with two absolute strings passes through unchanged', () => { + // Why: regression guard — the pre-existing absolute two-element form is + // the canonical shape and must not trigger the new UserError. + const filter = { + member: 'Orders.createdAt', + operator: 'inDateRange', + values: ['2024-01-01', '2024-01-31'], + }; + + const result = normalizeDateFilterValues(filter, 'UTC'); + + expect(result).toBe(filter); + }); + + test('invalid relative date string raises UserError', () => { + // Why: failure must surface at the API boundary with a clear HTTP 400, + // not as an opaque SQL error after the query reaches the database. + expect(() => normalizeDateFilterValues( + { member: 'Orders.createdAt', operator: 'inDateRange', values: ['definitely not a date'] }, + 'UTC' + )).toThrow(/Can't parse date/); + }); +}); + +describe('normalizeQuery: date-range filter resolution', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(FIXED_NOW.getTime()); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('top-level inDateRange filter with relative string is resolved', () => { + // Why: even without an OR wrapper, a filter leaf with a relative date + // value must be resolved at the gateway. + const result = normalizeQuery({ + ...baseQuery, + filters: [ + { member: 'Orders.createdAt', operator: 'inDateRange', values: ['last 2 weeks'] }, + ], + }, false); + + const leaf = result.filters[0]; + expect(leaf.values).toHaveLength(2); + expect(leaf.values[0]).toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00\.000$/); + expect(leaf.values[1]).toMatch(/^\d{4}-\d{2}-\d{2}T23:59:59\.999$/); + }); + + test('inDateRange leaf nested inside OR is resolved (the actual feature)', () => { + // Why: this is the whole point. The recursive walker must reach leaves + // inside groups and apply the helper there too. + const result = normalizeQuery({ + ...baseQuery, + filters: [{ + or: [ + { member: 'Orders.createdAt', operator: 'inDateRange', values: ['last 2 weeks'] }, + { member: 'Orders.status', operator: 'equals', values: ['pending'] }, + ], + }], + }, false); + + const [dateLeaf, statusLeaf] = result.filters[0].or; + expect(dateLeaf.values).toHaveLength(2); + expect(dateLeaf.values[0]).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(statusLeaf.values).toEqual(['pending']); + }); + + test('inDateRange leaf nested inside AND is resolved', () => { + // Why: AND must work symmetrically with OR. + const result = normalizeQuery({ + ...baseQuery, + filters: [{ + and: [ + { member: 'Orders.createdAt', operator: 'inDateRange', values: ['last 2 weeks'] }, + { member: 'Orders.status', operator: 'equals', values: ['active'] }, + ], + }], + }, false); + + expect(result.filters[0].and[0].values).toHaveLength(2); + }); + + test('deeply nested date filter (OR inside AND) is resolved', () => { + // Why: the walker must recurse to arbitrary depth, not just one level. + const result = normalizeQuery({ + ...baseQuery, + filters: [{ + and: [ + { member: 'Orders.status', operator: 'equals', values: ['active'] }, + { + or: [ + { member: 'Orders.createdAt', operator: 'inDateRange', values: ['last 2 weeks'] }, + { member: 'Orders.priority', operator: 'equals', values: ['high'] }, + ], + }, + ], + }], + }, false); + + const dateLeaf = result.filters[0].and[1].or[0]; + expect(dateLeaf.values).toHaveLength(2); + expect(dateLeaf.values[0]).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + test('top-level timeDimensions.dateRange still resolves unchanged', () => { + // Why: invariant — existing queries that only use top-level timeDimensions + // must produce the same shape they always did. Both paths share + // resolveDateRange so they cannot diverge. + const result = normalizeQuery({ + ...baseQuery, + timeDimensions: [ + { dimension: 'Orders.createdAt', dateRange: 'last 2 weeks' }, + ], + }, false); + + const td = result.timeDimensions[0]; + expect(td.dateRange).toHaveLength(2); + expect(td.dateRange[0]).toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00\.000$/); + expect(td.dateRange[1]).toMatch(/^\d{4}-\d{2}-\d{2}T23:59:59\.999$/); + }); + + test('invalid relative date inside OR raises UserError at gateway', () => { + // Why: a malformed relative date must fail at the API boundary with a + // clear message, not deep in the SQL planner. + expect(() => normalizeQuery({ + ...baseQuery, + filters: [{ + or: [ + { member: 'Orders.createdAt', operator: 'inDateRange', values: ['definitely not a date'] }, + { member: 'Orders.status', operator: 'equals', values: ['pending'] }, + ], + }], + }, false)).toThrow(/Can't parse date/); + }); + + test('timezone from query is honored for relative dates inside OR', () => { + // Why: the resolver must use the query timezone. We pin this with a + // day-boundary instant where UTC and LA fall on different calendar days. + const dayBoundaryNow = new Date(Date.UTC(2026, 5, 25, 2, 0, 0, 0)); + jest.spyOn(Date, 'now').mockReturnValue(dayBoundaryNow.getTime()); + + const utc = normalizeQuery({ + ...baseQuery, + timezone: 'UTC', + filters: [{ or: [ + { member: 'Orders.createdAt', operator: 'inDateRange', values: ['today'] }, + ]}], + }, false); + + const la = normalizeQuery({ + ...baseQuery, + timezone: 'America/Los_Angeles', + filters: [{ or: [ + { member: 'Orders.createdAt', operator: 'inDateRange', values: ['today'] }, + ]}], + }, false); + + expect(utc.filters[0].or[0].values[0]).toMatch(/^2026-06-25T/); + expect(la.filters[0].or[0].values[0]).toMatch(/^2026-06-24T/); + }); + + test('non-date filters inside OR are untouched', () => { + // Why: regression guard — equals/contains/etc. must not be modified. + const result = normalizeQuery({ + ...baseQuery, + filters: [{ + or: [ + { member: 'Orders.status', operator: 'equals', values: ['active'] }, + { member: 'Orders.status', operator: 'equals', values: ['pending'] }, + ], + }], + }, false); + + expect(result.filters[0].or[0].values).toEqual(['active']); + expect(result.filters[0].or[1].values).toEqual(['pending']); + }); +}); + +// resolveDateRange is the shared resolver used by both timeDimensions.dateRange +// (line 409) and normalizeDateFilterValues. Pinning its behavior here keeps +// the filter path and the timeDimensions path from diverging. +describe('resolveDateRange', () => { + test('YYYY-MM-DD start expands to start-of-day, end to end-of-day', () => { + // Why: this formatting invariant is what makes the two paths byte-equal. + expect(resolveDateRange(['2026-01-01', '2026-01-31'], 'UTC')).toEqual([ + '2026-01-01T00:00:00.000', + '2026-01-31T23:59:59.999', + ]); + }); + + test('single-element array expands to [v, v] then normalizes', () => { + // Why: legacy "single day" shorthand for dateRange. + expect(resolveDateRange(['2026-01-15'], 'UTC')).toEqual([ + '2026-01-15T00:00:00.000', + '2026-01-15T23:59:59.999', + ]); + }); + + test('undefined input returns undefined (granularity-only timeDimensions)', () => { + // Why: a timeDimension may have only `granularity` and no `dateRange`. + expect(resolveDateRange(undefined, 'UTC')).toBeUndefined(); + }); +}); diff --git a/packages/cubejs-schema-compiler/test/integration/postgres/or-date-filter.test.ts b/packages/cubejs-schema-compiler/test/integration/postgres/or-date-filter.test.ts new file mode 100644 index 0000000000000..a916b0395d512 --- /dev/null +++ b/packages/cubejs-schema-compiler/test/integration/postgres/or-date-filter.test.ts @@ -0,0 +1,218 @@ +import { prepareJsCompiler } from '../../unit/PrepareCompiler'; +import { dbRunner } from './PostgresDBRunner'; + +// End-to-end coverage for date filters inside OR/AND groups. The gateway +// resolves relative strings ("last 2 weeks") to absolute ISO datetimes before +// the query reaches the schema compiler; these tests feed already-absolute +// values to focus on planner correctness under both the legacy JS planner +// and Tesseract. The point is that a date predicate expressed inside `filters.or` +// / `filters.and` must (a) appear as ONE branch of that logical group in the +// executed SQL, and (b) produce the expected row set against real data. +// +// Inline data (7 rows): +// id status created_at +// 1 active 2024-01-05 +// 2 active 2024-01-15 +// 3 pending 2024-02-01 +// 4 pending 2024-02-20 +// 5 archived 2024-03-10 +// 6 active 2024-06-10 +// 7 cancelled 2024-06-25 +describe('OR/AND date filter integration', () => { + jest.setTimeout(200000); + + const { compiler, joinGraph, cubeEvaluator } = prepareJsCompiler(` + cube('orders', { + sql: \` + SELECT 1 as id, 'active' as status, '2024-01-05T00:00:00.000Z'::timestamptz as created_at UNION ALL + SELECT 2 as id, 'active' as status, '2024-01-15T00:00:00.000Z'::timestamptz as created_at UNION ALL + SELECT 3 as id, 'pending' as status, '2024-02-01T00:00:00.000Z'::timestamptz as created_at UNION ALL + SELECT 4 as id, 'pending' as status, '2024-02-20T00:00:00.000Z'::timestamptz as created_at UNION ALL + SELECT 5 as id, 'archived' as status, '2024-03-10T00:00:00.000Z'::timestamptz as created_at UNION ALL + SELECT 6 as id, 'active' as status, '2024-06-10T00:00:00.000Z'::timestamptz as created_at UNION ALL + SELECT 7 as id, 'cancelled' as status, '2024-06-25T00:00:00.000Z'::timestamptz as created_at + \`, + + measures: { + count: { type: 'count' } + }, + + dimensions: { + id: { sql: 'id', type: 'number', primaryKey: true, shown: true }, + status: { sql: 'status', type: 'string' }, + createdAt: { sql: 'created_at', type: 'time' } + } + }) + `); + + // Why: this is the whole feature — a date range restricted to ONE branch of + // an OR group. Rows 1 & 2 match the date window; row 3 matches by status. + // Legacy behavior (date pulled into a global AND) would exclude row 3. + it('inDateRange inside OR: date window OR status match', async () => dbRunner.runQueryTest({ + measures: ['orders.count'], + dimensions: ['orders.id'], + filters: [{ + or: [ + { + member: 'orders.createdAt', + operator: 'inDateRange', + values: ['2024-01-01T00:00:00.000', '2024-01-31T23:59:59.999'], + }, + { member: 'orders.status', operator: 'equals', values: ['pending'] }, + ], + }], + order: [{ id: 'orders.id' }], + timezone: 'UTC', + }, [ + { orders__id: 1, orders__count: '1' }, + { orders__id: 2, orders__count: '1' }, + { orders__id: 3, orders__count: '1' }, + { orders__id: 4, orders__count: '1' }, + ], { joinGraph, cubeEvaluator, compiler })); + + // Why: AND must produce the symmetric structure — both branches inside a + // single grouped condition. Only rows in the date window AND with status + // 'active' should match: rows 1 and 2. + it('inDateRange inside AND: date window AND status match', async () => dbRunner.runQueryTest({ + measures: ['orders.count'], + dimensions: ['orders.id'], + filters: [{ + and: [ + { + member: 'orders.createdAt', + operator: 'inDateRange', + values: ['2024-01-01T00:00:00.000', '2024-01-31T23:59:59.999'], + }, + { member: 'orders.status', operator: 'equals', values: ['active'] }, + ], + }], + order: [{ id: 'orders.id' }], + timezone: 'UTC', + }, [ + { orders__id: 1, orders__count: '1' }, + { orders__id: 2, orders__count: '1' }, + ], { joinGraph, cubeEvaluator, compiler })); + + // Why: `beforeOrOnDate` is one of the two operators ovr flagged as needing + // end-of-day boundary semantics (Tesseract's `<= endOfDay`). The gateway + // resolves the relative form to T23:59:59.999; passing that same absolute + // value through here confirms the compiler respects it and the row for + // 2024-01-15 (row 2) is included at the boundary. + it('beforeOrOnDate at end-of-day boundary includes rows on that day', async () => dbRunner.runQueryTest({ + measures: ['orders.count'], + dimensions: ['orders.id'], + filters: [{ + or: [ + { + member: 'orders.createdAt', + operator: 'beforeOrOnDate', + values: ['2024-01-15T23:59:59.999'], + }, + { member: 'orders.status', operator: 'equals', values: ['cancelled'] }, + ], + }], + order: [{ id: 'orders.id' }], + timezone: 'UTC', + }, [ + { orders__id: 1, orders__count: '1' }, + { orders__id: 2, orders__count: '1' }, + { orders__id: 7, orders__count: '1' }, + ], { joinGraph, cubeEvaluator, compiler })); + + // Why: `afterDate` is the second operator with end-of-day semantics + // (Tesseract's `> endOfDay`). "After 2024-02-20" must exclude that day + // itself — row 4 (2024-02-20) must NOT appear. Row 5 (2024-03-10) and + // beyond do. + it('afterDate at end-of-day boundary excludes rows on that day', async () => dbRunner.runQueryTest({ + measures: ['orders.count'], + dimensions: ['orders.id'], + filters: [{ + or: [ + { + member: 'orders.createdAt', + operator: 'afterDate', + values: ['2024-02-20T23:59:59.999'], + }, + { member: 'orders.status', operator: 'equals', values: ['pending'] }, + ], + }], + order: [{ id: 'orders.id' }], + timezone: 'UTC', + }, [ + { orders__id: 3, orders__count: '1' }, + { orders__id: 4, orders__count: '1' }, + { orders__id: 5, orders__count: '1' }, + { orders__id: 6, orders__count: '1' }, + { orders__id: 7, orders__count: '1' }, + ], { joinGraph, cubeEvaluator, compiler })); + + // Why: `onTheDate` was broken in both planners before this PR. Legacy read + // values[0]/values[1] and returned garbage when only one value was + // supplied; Tesseract errored with "2 arguments expected". After the fix + // the gateway resolves it to a two-value range, so `onTheDate: [date]` + // must return exactly the rows on that calendar day. + it('onTheDate with resolved two-value range matches only that day', async () => dbRunner.runQueryTest({ + measures: ['orders.count'], + dimensions: ['orders.id'], + filters: [{ + and: [ + { + member: 'orders.createdAt', + operator: 'onTheDate', + values: ['2024-02-01T00:00:00.000', '2024-02-01T23:59:59.999'], + }, + ], + }], + order: [{ id: 'orders.id' }], + timezone: 'UTC', + }, [ + { orders__id: 3, orders__count: '1' }, + ], { joinGraph, cubeEvaluator, compiler })); + + // Why: the walker has to recurse through arbitrary depth. A bug that stopped + // at depth 1 would drop the date predicate silently. Here we ask for + // status='active' AND (created in Jan OR status='pending'). Only rows 1 + // and 2 satisfy both parts. + it('date filter nested two levels deep (OR inside AND)', async () => dbRunner.runQueryTest({ + measures: ['orders.count'], + dimensions: ['orders.id'], + filters: [{ + and: [ + { member: 'orders.status', operator: 'equals', values: ['active'] }, + { + or: [ + { + member: 'orders.createdAt', + operator: 'inDateRange', + values: ['2024-01-01T00:00:00.000', '2024-01-31T23:59:59.999'], + }, + { member: 'orders.status', operator: 'equals', values: ['urgent'] }, + ], + }, + ], + }], + order: [{ id: 'orders.id' }], + timezone: 'UTC', + }, [ + { orders__id: 1, orders__count: '1' }, + { orders__id: 2, orders__count: '1' }, + ], { joinGraph, cubeEvaluator, compiler })); + + // Why: existing queries that use only top-level `timeDimensions.dateRange` + // must continue producing the same results — this is the invariant the PR + // is built on. If the compiler started routing top-level dateRange through + // the new filter code path, this test would surface a regression. + it('regression guard: top-level timeDimensions.dateRange still filters correctly', async () => dbRunner.runQueryTest({ + measures: ['orders.count'], + dimensions: ['orders.id'], + timeDimensions: [{ + dimension: 'orders.createdAt', + dateRange: ['2024-01-01', '2024-01-31'], + }], + order: [{ id: 'orders.id' }], + timezone: 'UTC', + }, [ + { orders__id: 1, orders__count: '1' }, + { orders__id: 2, orders__count: '1' }, + ], { joinGraph, cubeEvaluator, compiler })); +});