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 SUM … OVER (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 noWHEREat 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
ORcannot 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
:paramstays 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
- Replicate. Each source table is copied as-is into the workspace’s local DuckDB warehouse. This step is expensive, network-bound and touches production.
- 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 BYin 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