Datasets and cubes

The model layer of Xeplr Analytics. It covers datasets, nested datasets, filters and parameters, cubes built on a local DuckDB replica, and how compiled SQL is kept safe.

A dataset

A dataset is a graph of tables drawn on a canvas. It has tables, the joins between them, columns with optional aggregates, formula columns, ranking columns, filters and parameters. You define it once, and reports and dashboard widgets read from it. The number is computed in one place, so two dashboards cannot disagree about it.

Part What it compiles to
Tables and joins SELECT … JOIN … over a spanning tree of the relationships. A table with no join produces a CROSS JOIN warning
Columns <alias>_<column>, wrapped in the column’s aggregate. Non-aggregated columns go into GROUP BY
Formula columns the dialect’s template from the formula catalog (GET /expression-functions?dbType=), a CASE, or custom SQL
Ranking columns RANK(), ROW_NUMBER(), running SUMOVER (PARTITION BY … ORDER BY …)
Filters WHERE, with the ranking filter applied in a wrapping query

A dataset reads from a saved connection and database, or from a cube (sourceCubeId). The canvas preview runs the SQL capped at 50 rows. It checks the shape of the result and is not an export.

One compiler, two places

@xeplr-bi/query-engine runs in the browser to show a query and on the server to build the only query that is ever executed. The API takes a saved report or dashboard id plus the reader’s filter values. It never takes SQL text for a saved document, and it discards filter values for filters the document does not declare. A preview cannot disagree with a real run, because only one compiler exists.

Datasets are wrapped, never edited

Datasets are shared, so a report never changes one. Each stage adds a subquery around the previous one:

1. dataset SQL (buildSql)          never edited
2. + DB formulas     wrapped       SELECT *, <expr> AS "alias" FROM (…) AS report_computed
3. + pushed filters  wrapped       only filters provably equivalent to the in-memory ones
4. + named params    bound         :month, :start_date, :<filter tag> …
5. + DB filters      wrapped       SELECT * FROM (…) AS report_dbfiltered WHERE …

Placeholder numbering carries across every stage. Without that, $1 or @p0 emitted twice would bind the wrong value, and MySQL’s positional ? would hide the bug.

Nested datasets

A dataset can use another dataset as a source. Nested datasets compile bottom up.

  • A cycle is refused when you save, before the save runs. Otherwise it would show up later as a stack overflow at compile time.
  • Depth is capped (10 by default), and a too-deep chain gets a clear message instead of a database timeout.
  • A nested dataset must use the root’s source. A dataset on another connection is reported as a cross-connection problem. A cube counts as its own source (cube:<id>), so two cube datasets with no connection id do not compare as the same source.

Filters

@xeplr-bi/engine defines what a filter is, once, for reports, dashboards and cube builds.

Operator Label Values
= / != equals / does not equal one, from a list
IN / NOT IN is any of / is none of many
LIKE / NOT LIKE contains / does not contain text
STARTS WITH / ENDS WITH starts with / ends with (compile to LIKE) text
> >= < <= same text
IS NULL / IS NOT NULL is empty / is not empty none
  • Filter logic combines filters by tag: date and (region or channel). An unknown tag is reported as a typo. A broken expression returns an error and compiles to no WHERE at all. It never falls back to joining every filter with AND, because that would be a different report.
  • An incomplete filter is skipped, never guessed at. A tag with no usable value is dropped, so an unset filter inside OR cannot match the whole table.
  • The date filter always exists and cannot be cleared. Its value resolves from the session, then the report default, then the workspace default, then the current month. Without a date, a query would scan all history. It always compiles to a range, and several bound date columns are joined with AND.
  • A reader’s filter choice is never saved into the report. Only defaults are stored. Dashboards remember a reader’s choices per board in that browser.
  • LIKE text matches literally. User % and _ are escaped before the operator’s own wildcards are added, so “50%” means “50%”.

Report row filters (eq, contains, in, empty and the rest) run in the report engine after the query. The engine pushes a filter into SQL only when the SQL result can keep more rows than the in-memory filter, never fewer. ne, notContains and notEmpty are never pushed, because SQL <> drops NULLs and the in-memory filter keeps them.

Parameters

Parameters are written into dataset SQL as :name and bound as placeholders.

Parameter Resolves to
:today, :today_ly today, and the same day last year
:first_day_m, :first_day_q, :first_day_y, :first_day_ly the start of this month, quarter, year, and last year
:first_day_fy, :first_day_fyly the start of this financial year, and of last financial year
:month, :year the current month and year
:start_date, :end_date the report’s date range
:<filter tag> that filter’s value
  • The financial year starts in April by default. The start month is an explicit input, because hardcoding it gets the year wrong for most of the world.
  • An unresolved :param stays in the SQL text and is reported, so the database errors instead of running a different query. An unresolved parameter inside a per-column filter value drops that filter, because a filter that matches nothing looks like a successful empty report.
  • An unanswered prompt parameter is returned as missing so the caller can ask for it. It is never silently ignored.
  • A quoted ':name' stays as text and is reported, because it is almost never intended.

Dialects

Identifier Placeholder
postgres (default) "name" $1, $2 …
mssql [name] @p0, @p1 …
mysql `name` ?

Reports and dashboards

Grouping, pivots, subtotals, top-N, “show as” and streaks are computed by @xeplr-bi/report-engine, the only implementation. It runs in the browser in design mode and on the server over a full database stream. No SQL version exists, because dialects disagree (an MSSQL AVG over an integer truncates) and a second implementation would let design mode and production show different numbers.

  • Totals are accumulated from raw rows, never summed from displayed rows, because an average of averages is not the average.
  • Memory grows with distinct cells, not rows. The server stops at 50 MB of estimated memory and answers 413.
  • Long runs return an id at once. Progress arrives over server-sent events.

A dashboard model joins several datasets. Its columns are always qualified by model dataset (d1__amount), and only the smallest connecting set of datasets is joined. A dataset that cannot be reached is reported, because joining it would produce a cartesian product.

Cubes

A cube is a pre-aggregated copy of a table at a grain you choose. Use one when a source table is too large to query live.

Two stages

  1. Replicate. Each source table is copied as-is into the workspace’s local DuckDB warehouse. This step is expensive, network-bound and touches production.
  2. Build. The cube is built from the local copies. This step touches nobody and takes seconds.

The split keeps the cube builder interactive. Changing a dimension rebuilds from local data instead of re-scanning production.

Rule Why
One warehouse per workspace, created on demand, never chosen from a list a picker lets someone replicate the same table twice
A replica is upserted on a key or replaced. It is never appended append holds every row twice while every layer reports success. With no key, the replica is replaced
A failed refresh keeps the old copy an empty replica makes every cube answer zero
The source reads one table at a time. Joins happen only in DuckDB a cube canvas can span several connections, and the source dialect stops mattering
A table on the canvas with no join is refused it would multiply every row of the cube
LEFT is the default join an order whose customer row is missing must not disappear
Each table has a staleness tolerance in minutes, default 60 (0 means always copy) a refresh skips a table copied more recently than its tolerance and says why
A build writes a new versioned file and then switches to it readers never see a half-built cube

Build is the only action a cube needs. It brings the cube’s tables up to date and then rebuilds. A finished copy triggers a rebuild of every cube that reads that table, so jobs schedule the copy, never the build. A cube never rebuilds while a table it reads is still being copied.

The spec

Field Meaning
grain.time a time column and a bucket: hour (the default), day, week, month, year
grain.dimensions the group-by columns
measures Sum, Avg, Min, Max, Count, CountDistinct on a column
filters fixed at build time (country = 'IN'). They make the cube smaller
reportTimezone buckets are cut in this zone at build time. A second zone needs a second cube
sourceTimezones what a timestamp column without a time zone means, per column. Unset means UTC, with a warning

A filter column costs what a dimension costs. To filter a cube by region, region has to be in the grain. The one exception is a build-time filter.

Rollable and stored measures

A cube stores additive slots, not the measures you asked for.

Measure Stored as Rolls up to a coarser grain
Sum, Count Sum__x, Count__x yes, by adding
Min, Max Min__x, Max__x yes, by taking the min or max
Avg Sum__x and Count__x, divided at read time yes. A stored average would be wrong once rolled up
CountDistinct the distinct count at the cube’s grain no (rollable: false)

A customer active in two regions is one distinct customer by month and two by month and region. A distinct count is exact at the grain it was built for and cannot be added up. The builder lets you choose it and shows why. A widget that needs it at a coarser grain is not planned onto the cube and is not silently dropped.

Sizing a cube

Cardinality is the lever, not column count. Five low-cardinality dimensions turn 10M rows into 20k. Add one order_id and the cube is the size of the table.

The builder profiles every column without scanning production:

Source Distinct counts from Shown as
PostgreSQL pg_stats, pg_class ~n
MySQL index cardinalities (indexed columns only) ~n
SQL Server row count from dm_db_partition_stats; distinct counts from a sample, or APPROX_COUNT_DISTINCT (one full pass) on request ≥ n sampled, ~n approximate
DuckDB replica approx_count_distinct over the whole table ~n
gaps in the catalog a 100k-row sample ≥ n, a lower bound

A sampled figure is a floor. Treat it as a floor, because calling it “about” is how a cube gets built far larger than its estimate.

The size verdict waits for an exact count on Review. The estimate multiplies cardinalities as if the columns were independent, and real columns are not. On a real five-table canvas it read 5,006,421 rows (“not worth building”) for a cube whose true size was 3,156,890. So the design steps show no size figure. Review shows the estimate bar, and gives a verdict only after Count it again runs the exact GROUP BY. That count is affordable because it reads the local replica: five million rows took 731ms. The verdict is not worth building under 2x compression, ok under 20x, and good above that.

Physical order is the partitioning

DuckDB has no table partitions. It has row groups with per-column min/max, so a range filter skips groups it cannot match. That means you arrange physical order yourself. Measured on 20 million rows, filtering one month out of 36:

build cost ranged scan
unsorted none 12ms
one local ORDER BY pass after load 1.15s 1ms
CREATE INDEX on the timestamp 7.63s 12ms, no benefit
  • Sort locally, never at the source. An ORDER BY in the query that reads production forces the sort onto the production box.
  • Do not add an index for this. An index serves point lookups, not range aggregates over a columnar table.
  • Send high-cardinality drill-down to the replica, not a cube. A cube keyed on a 5,000-value employee id came out at 19.1M rows from a 20M source. The same filter on the replica plus a date range took 0.6ms.

SQL safety

The server compiles a dataset in strict mode, with the connection’s dialect:

What goes into the statement How it is made safe
filter and ranking values escaped for the dialect (quotes doubled, backslashes also doubled on MySQL, NUL refused); numbers and :parameters as they are
operators, join types, sort directions, window functions taken from fixed lists. Anything else throws
table, alias and column names, function names outside the catalog letters, digits, _ and $ only, with dots between schema parts
formula text (custom formulas, formula joins and filters) not validated. It is SQL its author is trusted to write, so the connection that runs it should be read-only

A filter operator that is not on the list throws (Unsupported filter operator) instead of being skipped. Operators are written into SQL and saved configuration arrives through an API, so = 1 OR 1=1 -- would otherwise be stored SQL injection. A skipped filter would return more rows than were asked for.

Queries on source databases run over read-only connections: previews, samples, the report grid and report and dashboard aggregation. PostgreSQL and MySQL enforce this in the driver. SQL Server does not, so give Xeplr Analytics a login with only db_datareader on source databases. See Security.

Last updated 16 September 2026