555 lines
19 KiB
TypeScript
555 lines
19 KiB
TypeScript
/**
|
|
* tests/analytics/query-request-contract.test.ts
|
|
* ==============================================
|
|
* BI-P016 — QUERY REQUEST CONTRACT.
|
|
*
|
|
* Two claims are under test, and both are stated so they can FAIL:
|
|
*
|
|
* 1. "The default product/application API must NOT accept arbitrary SQL."
|
|
* Tested by feeding the parser every field name a caller would reach for
|
|
* to smuggle SQL and asserting a rejection — not by asserting the type
|
|
* lacks the field, which a compiler already guarantees and a JSON body
|
|
* ignores.
|
|
*
|
|
* 2. "Same request can compile to ClickHouse/semantic engine without
|
|
* consumer rewrite." Tested by projecting BOTH compiled artefacts onto a
|
|
* comparable shape and demanding equality. The ClickHouse side of that
|
|
* projection is obtained by PARSING the emitted SQL and its bound
|
|
* parameters here in the test — not by trusting a field the compiler
|
|
* helpfully returned. A compiler that silently drops a filter therefore
|
|
* fails this test instead of passing it.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { SiaxError } from '@/src/platform/errors';
|
|
import {
|
|
ANALYTICS_QUERY_REQUEST_KEYS,
|
|
FILTER_OPERATORS,
|
|
parseAnalyticsQueryRequest,
|
|
queryResolutionFrom,
|
|
resolveAnalyticsQuery,
|
|
type AnalyticsQueryRequest,
|
|
type FilterOperator,
|
|
type ResolvedDimension,
|
|
type ResolvedMetric,
|
|
} from '@/src/analytics/contract/query-request';
|
|
import {
|
|
compileRequestToClickHouse,
|
|
type CompiledAnalyticsQuery,
|
|
} from '@/src/analytics/contract/compile-clickhouse';
|
|
import {
|
|
compileRequestToSemantic,
|
|
shapeOfSemanticSpec,
|
|
type QueryShape,
|
|
} from '@/src/analytics/contract/compile-semantic';
|
|
import { FORCED_TENANT_PREDICATE } from '@/src/analytics/engine/query-compiler';
|
|
|
|
// ── the declared catalog under test ────────────────────────────────────────
|
|
|
|
const METRICS: ResolvedMetric[] = [
|
|
{
|
|
name: 'orders',
|
|
metric_id: 'm_orders',
|
|
event_metric_key: 'order_count',
|
|
aggregation: 'count',
|
|
allowed_dimensions: ['country', 'channel', 'segment'],
|
|
},
|
|
{
|
|
name: 'revenue',
|
|
metric_id: 'm_revenue',
|
|
event_metric_key: 'revenue_sek',
|
|
aggregation: 'sum',
|
|
allowed_dimensions: ['country', 'channel', 'segment'],
|
|
unit: 'SEK',
|
|
},
|
|
{
|
|
name: 'restricted',
|
|
metric_id: 'm_restricted',
|
|
event_metric_key: 'restricted_measure',
|
|
aggregation: 'sum',
|
|
// Deliberately NOT sliceable by `segment`.
|
|
allowed_dimensions: ['country'],
|
|
},
|
|
];
|
|
|
|
const DIMENSIONS: ResolvedDimension[] = [
|
|
{ name: 'country', dimension_id: 'd_country', field_name: 'country_code' },
|
|
{ name: 'channel', dimension_id: 'd_channel', field_name: 'sales_channel' },
|
|
{ name: 'segment', dimension_id: 'd_segment', field_name: 'customer_segment' },
|
|
];
|
|
|
|
const resolution = queryResolutionFrom({
|
|
dataset_id: 'ds_sales',
|
|
metrics: METRICS,
|
|
dimensions: DIMENSIONS,
|
|
semantic_model_version: 7,
|
|
defaultTimeDimension: 'event_time',
|
|
});
|
|
|
|
const FIELD_TO_NAME = new Map(DIMENSIONS.map((d) => [d.field_name, d.name]));
|
|
const KEY_TO_METRIC = new Map(METRICS.map((m) => [m.event_metric_key, m.name]));
|
|
|
|
function compile(req: AnalyticsQueryRequest): CompiledAnalyticsQuery {
|
|
return compileRequestToClickHouse(resolveAnalyticsQuery(req, resolution), {
|
|
tenant_id: 't_alpha',
|
|
project_id: 'p_main',
|
|
database: 'opt_analytics',
|
|
maxResultRows: 10_000,
|
|
});
|
|
}
|
|
|
|
// ── independent parser: SQL text + bound params -> comparable shape ────────
|
|
|
|
/** Split a WHERE clause on top-level " AND " (parenthesised lists survive). */
|
|
function splitConditions(where: string): string[] {
|
|
const out: string[] = [];
|
|
let depth = 0;
|
|
let current = '';
|
|
for (let i = 0; i < where.length; i += 1) {
|
|
const c = where[i];
|
|
if (c === '(') depth += 1;
|
|
if (c === ')') depth -= 1;
|
|
if (depth === 0 && where.startsWith(' AND ', i)) {
|
|
out.push(current);
|
|
current = '';
|
|
i += 4;
|
|
continue;
|
|
}
|
|
current += c;
|
|
}
|
|
if (current) out.push(current);
|
|
return out;
|
|
}
|
|
|
|
const OP_SYMBOL: Record<string, FilterOperator> = {
|
|
'=': 'eq',
|
|
'!=': 'ne',
|
|
'<': 'lt',
|
|
'<=': 'lte',
|
|
'>': 'gt',
|
|
'>=': 'gte',
|
|
};
|
|
|
|
/**
|
|
* Reconstruct the query's meaning from the artefact the engine would actually
|
|
* send. Anything the compiler failed to emit is invisible here, which is the
|
|
* point.
|
|
*/
|
|
function shapeOfCompiledSql(c: CompiledAnalyticsQuery): QueryShape {
|
|
const p = (key: string): string => {
|
|
const v = c.params[`param_${key}`];
|
|
if (v === undefined) throw new Error(`compiled SQL references unbound parameter ${key}`);
|
|
return v;
|
|
};
|
|
const whereMatch = /\sWHERE\s(.*?)(?:\sGROUP BY\s|\sORDER BY\s|\sLIMIT\s)/.exec(c.sql);
|
|
if (!whereMatch) throw new Error('compiled SQL has no WHERE clause');
|
|
const conditions = splitConditions(whereMatch[1]);
|
|
|
|
const filters: QueryShape['filters'] = [];
|
|
let timeFrom: string | undefined;
|
|
let timeTo: string | undefined;
|
|
|
|
for (const cond of conditions) {
|
|
if (cond === FORCED_TENANT_PREDICATE) continue;
|
|
if (/^project_id = \{project:String\}$/.test(cond)) continue;
|
|
if (/^dataset_id = \{dataset:String\}$/.test(cond)) continue;
|
|
if (/^metric IN \(/.test(cond)) continue;
|
|
|
|
let m = /^\w+ >= parseDateTime64BestEffort\(\{fromTime:String\}\)$/.exec(cond);
|
|
if (m) {
|
|
timeFrom = p('fromTime');
|
|
continue;
|
|
}
|
|
m = /^\w+ < parseDateTime64BestEffort\(\{toTime:String\}\)$/.exec(cond);
|
|
if (m) {
|
|
timeTo = p('toTime');
|
|
continue;
|
|
}
|
|
m = /^position\(dim_values\[indexOf\(dim_names, \{(fn\d+):String\}\)\], \{(fv\d+):String\}\) > 0$/.exec(
|
|
cond,
|
|
);
|
|
if (m) {
|
|
filters.push({ dimension: dimNameFor(p(m[1])), operator: 'contains', values: [p(m[2])] });
|
|
continue;
|
|
}
|
|
m = /^(NOT )?has\(dim_names, \{(fn\d+):String\}\)$/.exec(cond);
|
|
if (m) {
|
|
filters.push({
|
|
dimension: dimNameFor(p(m[2])),
|
|
operator: m[1] ? 'is_null' : 'is_not_null',
|
|
values: [],
|
|
});
|
|
continue;
|
|
}
|
|
m = /^dim_values\[indexOf\(dim_names, \{(fn\d+):String\}\)\] (NOT IN|IN) \((.+)\)$/.exec(cond);
|
|
if (m) {
|
|
const keys = m[3].split(', ').map((t) => /^\{(fv\d+_\d+):String\}$/.exec(t)?.[1]);
|
|
if (keys.some((k) => !k)) throw new Error(`IN list contains a non-parameter token: ${m[3]}`);
|
|
filters.push({
|
|
dimension: dimNameFor(p(m[1])),
|
|
operator: m[2] === 'NOT IN' ? 'not_in' : 'in',
|
|
values: keys.map((k) => p(k as string)),
|
|
});
|
|
continue;
|
|
}
|
|
m = /^dim_values\[indexOf\(dim_names, \{(fn\d+):String\}\)\] (=|!=|<=|>=|<|>) \{(fv\d+):String\}$/.exec(
|
|
cond,
|
|
);
|
|
if (m) {
|
|
filters.push({ dimension: dimNameFor(p(m[1])), operator: OP_SYMBOL[m[2]], values: [p(m[3])] });
|
|
continue;
|
|
}
|
|
throw new Error(`unrecognised compiled condition (possible unbound SQL text): ${cond}`);
|
|
}
|
|
|
|
const selectMatch = /^SELECT (.*?) FROM /.exec(c.sql);
|
|
if (!selectMatch) throw new Error('compiled SQL has no SELECT list');
|
|
const metricKeys = [...selectMatch[1].matchAll(/\{(m\d+):String\}/g)].map((mm) => p(mm[1]));
|
|
const dimKeys = [...selectMatch[1].matchAll(/\{(d\d+):String\}/g)].map((mm) => p(mm[1]));
|
|
const grainMatch = /toStartOf(\w+)\(/.exec(selectMatch[1]);
|
|
|
|
const orderMatch = /\sORDER BY\s(.*?)\sLIMIT\s/.exec(c.sql);
|
|
const order = orderMatch
|
|
? orderMatch[1].split(', ').map((tok) => {
|
|
const [alias, dir] = tok.split(' ');
|
|
const name = c.metricAliases[alias] ?? c.dimensionAliases[alias];
|
|
if (!name) throw new Error(`ORDER BY references unknown alias ${alias}`);
|
|
return `${name}:${dir.toLowerCase()}`;
|
|
})
|
|
: [];
|
|
|
|
const limitMatch = /\sLIMIT (\d+) FORMAT JSON$/.exec(c.sql);
|
|
if (!limitMatch) throw new Error('compiled SQL has no LIMIT');
|
|
|
|
return {
|
|
dataset_id: p('dataset'),
|
|
metrics: metricKeys.map((k) => KEY_TO_METRIC.get(k) ?? k).sort(),
|
|
dimensions: dimKeys.map((k) => FIELD_TO_NAME.get(k) ?? k).sort(),
|
|
filters: filters.sort((a, b) => (a.dimension + a.operator).localeCompare(b.dimension + b.operator)),
|
|
order,
|
|
limit: Number(limitMatch[1]),
|
|
timeFrom,
|
|
timeTo,
|
|
grain: grainMatch ? (grainMatch[1].toLowerCase() as QueryShape['grain']) : undefined,
|
|
};
|
|
}
|
|
|
|
function dimNameFor(field: string): string {
|
|
const name = FIELD_TO_NAME.get(field);
|
|
if (!name) throw new Error(`compiled SQL bound an unknown physical dimension "${field}"`);
|
|
return name;
|
|
}
|
|
|
|
// ── 1. the API does not accept SQL ─────────────────────────────────────────
|
|
|
|
describe('BI-P016 — the product API accepts no SQL', () => {
|
|
const base = { dataset: 'ds_sales', metrics: ['orders'] };
|
|
|
|
const smuggling = [
|
|
'sql',
|
|
'query',
|
|
'rawSql',
|
|
'raw_sql',
|
|
'raw',
|
|
'where',
|
|
'having',
|
|
'expression',
|
|
'expr',
|
|
'select',
|
|
'from',
|
|
'groupBy',
|
|
'group_by',
|
|
'unsafe',
|
|
];
|
|
|
|
it.each(smuggling)('rejects a request carrying a %s field', (key) => {
|
|
expect(() => parseAnalyticsQueryRequest({ ...base, [key]: 'SELECT 1' })).toThrowError(SiaxError);
|
|
try {
|
|
parseAnalyticsQueryRequest({ ...base, [key]: 'SELECT 1' });
|
|
} catch (e) {
|
|
expect((e as SiaxError).code).toBe('INVALID_REQUEST');
|
|
}
|
|
});
|
|
|
|
// The INF0 Wave-2 lesson: a tenant read out of a request body. The contract
|
|
// must not merely IGNORE such a field; ignoring it is what lets a later
|
|
// refactor start honouring it.
|
|
it.each(['tenant', 'tenant_id', 'tenantId', 'project_id', 'database', 'catalog', 'table'])(
|
|
'rejects caller-supplied %s (tenant/catalog selection is never a request field)',
|
|
(key) => {
|
|
expect(() => parseAnalyticsQueryRequest({ ...base, [key]: 't_beta' })).toThrowError(SiaxError);
|
|
},
|
|
);
|
|
|
|
it('rejects ANY unknown key, not just the ones on the smell list', () => {
|
|
expect(() => parseAnalyticsQueryRequest({ ...base, somethingNewNextQuarter: 1 })).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('accepts exactly the eight BI-P016 fields and no more', () => {
|
|
expect([...ANALYTICS_QUERY_REQUEST_KEYS]).toEqual([
|
|
'dataset',
|
|
'metrics',
|
|
'dimensions',
|
|
'filters',
|
|
'timeRange',
|
|
'orderBy',
|
|
'limit',
|
|
'format',
|
|
]);
|
|
});
|
|
|
|
it('rejects SQL smuggled into a nested filter object', () => {
|
|
expect(() =>
|
|
parseAnalyticsQueryRequest({
|
|
...base,
|
|
filters: [{ dimension: 'country', operator: 'eq', values: ['SE'], sql: 'OR 1=1' }],
|
|
}),
|
|
).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('keeps the filter operator set closed', () => {
|
|
expect([...FILTER_OPERATORS]).toEqual([
|
|
'eq',
|
|
'ne',
|
|
'in',
|
|
'not_in',
|
|
'lt',
|
|
'lte',
|
|
'gt',
|
|
'gte',
|
|
'contains',
|
|
'is_null',
|
|
'is_not_null',
|
|
]);
|
|
expect(() =>
|
|
parseAnalyticsQueryRequest({
|
|
...base,
|
|
filters: [{ dimension: 'country', operator: 'raw', values: ['x'] }],
|
|
}),
|
|
).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('refuses an orderBy that is not selected (the obvious expression smuggling slot)', () => {
|
|
expect(() =>
|
|
parseAnalyticsQueryRequest({ ...base, orderBy: [{ ref: '(SELECT 1)', direction: 'asc' }] }),
|
|
).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('refuses a metric the catalog does not declare', () => {
|
|
const req = parseAnalyticsQueryRequest({ dataset: 'ds_sales', metrics: ['orders OR 1=1'] });
|
|
expect(() => resolveAnalyticsQuery(req, resolution)).toThrowError(SiaxError);
|
|
});
|
|
|
|
it('refuses slicing a metric by a dimension its definition disallows', () => {
|
|
const req = parseAnalyticsQueryRequest({
|
|
dataset: 'ds_sales',
|
|
metrics: ['restricted'],
|
|
dimensions: ['segment'],
|
|
});
|
|
try {
|
|
resolveAnalyticsQuery(req, resolution);
|
|
throw new Error('expected a rejection');
|
|
} catch (e) {
|
|
expect((e as SiaxError).code).toBe('AUTHORIZATION_DENIED');
|
|
}
|
|
});
|
|
|
|
it('refuses a dataset that is not the authorised one', () => {
|
|
const req = parseAnalyticsQueryRequest({ dataset: 'ds_other', metrics: ['orders'] });
|
|
try {
|
|
resolveAnalyticsQuery(req, resolution);
|
|
throw new Error('expected a rejection');
|
|
} catch (e) {
|
|
expect((e as SiaxError).code).toBe('AUTHORIZATION_DENIED');
|
|
}
|
|
});
|
|
|
|
it('rejects a reversed time range instead of silently returning zero rows', () => {
|
|
expect(() =>
|
|
parseAnalyticsQueryRequest({ ...base, timeRange: { from: '2026-02-01', to: '2026-01-01' } }),
|
|
).toThrowError(SiaxError);
|
|
});
|
|
});
|
|
|
|
// ── 2. one request, two targets, same meaning ──────────────────────────────
|
|
|
|
describe('BI-P016 DoD — one request compiles to ClickHouse and to the semantic engine', () => {
|
|
const requests: Array<[string, unknown]> = [
|
|
['metric only', { dataset: 'ds_sales', metrics: ['orders'] }],
|
|
[
|
|
'two metrics, two dimensions',
|
|
{ dataset: 'ds_sales', metrics: ['orders', 'revenue'], dimensions: ['country', 'channel'] },
|
|
],
|
|
[
|
|
'filters of every value operator',
|
|
{
|
|
dataset: 'ds_sales',
|
|
metrics: ['revenue'],
|
|
dimensions: ['country'],
|
|
filters: [
|
|
{ dimension: 'country', operator: 'eq', values: ['SE'] },
|
|
{ dimension: 'channel', operator: 'in', values: ['web', 'retail'] },
|
|
{ dimension: 'segment', operator: 'not_in', values: ['test'] },
|
|
{ dimension: 'channel', operator: 'contains', values: ['we'] },
|
|
],
|
|
},
|
|
],
|
|
[
|
|
'time range with grain and ordering',
|
|
{
|
|
dataset: 'ds_sales',
|
|
metrics: ['revenue'],
|
|
dimensions: ['country'],
|
|
timeRange: { from: '2026-01-01T00:00:00Z', to: '2026-02-01T00:00:00Z', grain: 'day' },
|
|
orderBy: [{ ref: 'revenue', direction: 'desc' }],
|
|
limit: 50,
|
|
},
|
|
],
|
|
[
|
|
'presence filters',
|
|
{
|
|
dataset: 'ds_sales',
|
|
metrics: ['orders'],
|
|
filters: [
|
|
{ dimension: 'segment', operator: 'is_null' },
|
|
{ dimension: 'channel', operator: 'is_not_null' },
|
|
],
|
|
},
|
|
],
|
|
];
|
|
|
|
it.each(requests)('%s — both targets agree', (_label, raw) => {
|
|
const req = parseAnalyticsQueryRequest(raw);
|
|
const resolved = resolveAnalyticsQuery(req, resolution);
|
|
const ch = compileRequestToClickHouse(resolved, {
|
|
tenant_id: 't_alpha',
|
|
project_id: 'p_main',
|
|
database: 'opt_analytics',
|
|
maxResultRows: 10_000,
|
|
});
|
|
const sem = compileRequestToSemantic(resolved);
|
|
|
|
const fromSql = shapeOfCompiledSql(ch);
|
|
const fromSemantic = shapeOfSemanticSpec(sem);
|
|
|
|
expect(fromSql.dataset_id).toBe(fromSemantic.dataset_id);
|
|
expect(fromSql.metrics).toEqual(fromSemantic.metrics);
|
|
expect(fromSql.dimensions).toEqual(fromSemantic.dimensions);
|
|
expect(fromSql.filters).toEqual(fromSemantic.filters);
|
|
expect(fromSql.order).toEqual(fromSemantic.order);
|
|
expect(fromSql.limit).toBe(fromSemantic.limit);
|
|
expect(fromSql.timeFrom).toBe(fromSemantic.timeFrom);
|
|
expect(fromSql.timeTo).toBe(fromSemantic.timeTo);
|
|
expect(fromSql.grain).toBe(fromSemantic.grain);
|
|
});
|
|
|
|
it('carries the canonical metric_id into the semantic spec so the engine is replaceable', () => {
|
|
const req = parseAnalyticsQueryRequest({ dataset: 'ds_sales', metrics: ['revenue'] });
|
|
const spec = compileRequestToSemantic(resolveAnalyticsQuery(req, resolution));
|
|
expect(spec.metrics[0].metric_id).toBe('m_revenue');
|
|
expect(spec.semantic_model_version).toBe(7);
|
|
});
|
|
|
|
it('never puts a SQL predicate string in the semantic spec', () => {
|
|
const req = parseAnalyticsQueryRequest({
|
|
dataset: 'ds_sales',
|
|
metrics: ['orders'],
|
|
filters: [{ dimension: 'country', operator: 'eq', values: ["SE' OR '1'='1"] }],
|
|
});
|
|
const spec = compileRequestToSemantic(resolveAnalyticsQuery(req, resolution));
|
|
// The hostile value survives as DATA — structured, not concatenated.
|
|
expect(spec.where[0].values).toEqual(["SE' OR '1'='1"]);
|
|
expect(JSON.stringify(spec)).not.toMatch(/SELECT|WHERE\s/i);
|
|
});
|
|
});
|
|
|
|
// ── 3. tenant forcing survives every request shape ─────────────────────────
|
|
|
|
describe('BI-P011 forced tenant predicate survives the BI-P016 contract', () => {
|
|
it.each(requests())('compiled SQL forces the tenant predicate first: %s', (_label, raw) => {
|
|
const c = compile(parseAnalyticsQueryRequest(raw));
|
|
const where = /\sWHERE\s(.*?)(?:\sGROUP BY\s|\sORDER BY\s|\sLIMIT\s)/.exec(c.sql);
|
|
expect(where).not.toBeNull();
|
|
expect(splitConditions((where as RegExpExecArray)[1])[0]).toBe(FORCED_TENANT_PREDICATE);
|
|
expect(c.params.param_tenant).toBe('t_alpha');
|
|
});
|
|
|
|
it('refuses to compile with a blank tenant rather than emitting no predicate', () => {
|
|
const resolved = resolveAnalyticsQuery(
|
|
parseAnalyticsQueryRequest({ dataset: 'ds_sales', metrics: ['orders'] }),
|
|
resolution,
|
|
);
|
|
for (const blank of ['', ' ']) {
|
|
expect(() =>
|
|
compileRequestToClickHouse(resolved, {
|
|
tenant_id: blank,
|
|
database: 'opt_analytics',
|
|
maxResultRows: 100,
|
|
}),
|
|
).toThrowError(SiaxError);
|
|
}
|
|
});
|
|
|
|
it('puts no caller-supplied value anywhere in the SQL text', () => {
|
|
const hostile = "'; DROP TABLE opt_analytics_event; --";
|
|
const c = compile(
|
|
parseAnalyticsQueryRequest({
|
|
dataset: 'ds_sales',
|
|
metrics: ['orders'],
|
|
filters: [{ dimension: 'country', operator: 'eq', values: [hostile] }],
|
|
}),
|
|
);
|
|
expect(c.sql).not.toContain(hostile);
|
|
expect(c.sql).not.toContain('DROP');
|
|
expect(Object.values(c.params)).toContain(hostile);
|
|
});
|
|
|
|
it('clamps the limit down to the governance ceiling and never up', () => {
|
|
const resolved = resolveAnalyticsQuery(
|
|
parseAnalyticsQueryRequest({ dataset: 'ds_sales', metrics: ['orders'], limit: 1_000_000 }),
|
|
resolution,
|
|
);
|
|
const c = compileRequestToClickHouse(resolved, {
|
|
tenant_id: 't_alpha',
|
|
database: 'opt_analytics',
|
|
maxResultRows: 500,
|
|
});
|
|
expect(c.effectiveLimit).toBe(500);
|
|
expect(c.sql).toContain('LIMIT 500');
|
|
});
|
|
});
|
|
|
|
function requests(): Array<[string, unknown]> {
|
|
return [
|
|
['bare', { dataset: 'ds_sales', metrics: ['orders'] }],
|
|
['grouped', { dataset: 'ds_sales', metrics: ['orders'], dimensions: ['country'] }],
|
|
[
|
|
'filtered',
|
|
{
|
|
dataset: 'ds_sales',
|
|
metrics: ['orders'],
|
|
filters: [{ dimension: 'country', operator: 'in', values: ['SE', 'NO'] }],
|
|
},
|
|
],
|
|
[
|
|
'time-bucketed',
|
|
{ dataset: 'ds_sales', metrics: ['revenue'], timeRange: { from: '2026-01-01', grain: 'month' } },
|
|
],
|
|
[
|
|
'everything at once',
|
|
{
|
|
dataset: 'ds_sales',
|
|
metrics: ['orders', 'revenue'],
|
|
dimensions: ['country', 'channel'],
|
|
filters: [
|
|
{ dimension: 'segment', operator: 'ne', values: ['internal'] },
|
|
{ dimension: 'channel', operator: 'is_not_null' },
|
|
],
|
|
timeRange: { from: '2026-01-01', to: '2026-03-01', grain: 'week' },
|
|
orderBy: [{ ref: 'revenue', direction: 'desc' }],
|
|
limit: 25,
|
|
format: 'arrow_ipc',
|
|
},
|
|
],
|
|
];
|
|
}
|