Request payload for apps.engine.run_dataset_query.
start_date/end_date are calendar dates, not datetimes — the frontend
always sends date-only YYYY-MM-DD values. end_date is INCLUSIVE for
the caller; see half_open_window for the half-open datetime conversion
the engine actually queries against. granularity buckets trend results
via apps.engine.sql_builder.SqlBuilder.date_trunc.
group_by/order_by are deliberately not accepted: free-text
grouping/ordering from a client would be an injection surface against
dataset-authored SQL. Datasets bake their own ordering, and grouping
comes from granularity. sort is the one narrow exception: it carries
a declared column key, resolved server-side via resolve_sort — never
a raw SQL fragment.
search is free-text substring search, ORed across the dataset's
searchable=True columns (see DatasetSpec.searchable_columns and
resolve_search). This is distinct from filters, which are exact
IN-list matches against one named dimension each. The distinction
mirrors v1's /warehouse/ endpoint, which exposes BOTH a
warehouseName exact IN-list param and a separate search ILIKE param
(app/service/warehouses.py:154-157) — one endpoint, two independent
matching semantics, because "give me exactly these warehouses" and
"give me warehouses whose name contains this" are different questions.
page/size apply ONLY when DatasetSpec.paginated is True — for a
non-paginated (chart/trend) dataset they are accepted but ignored;
limit continues to govern how many rows THAT dataset's build
renders. page is 1-based to match v1's GET /warehouse/?page=1&size= 20&... and the frontend's existing pagination controls exactly; the
engine (apps.engine.sql_builder.SqlBuilder.limit_sql) is what converts
it to a 0-based OFFSET = (page - 1) * size, so no caller of this
request model ever has to do that arithmetic itself.