The pinned rules
Cube publishes no JSON Schema for its data model. The authoritative validator is the schema compiler’s Joi rules (packages/cubejs-schema-compiler/src/compiler/ CubeValidator.ts), applied after Cube’s YAML compiler camelizes every key. Dex
ports those rules for the fields it reads, pinned to one Cube release.
The fingerprint records that release beside every hash, so a later port that
reads a file differently is reported as a change of reader rather than as every
cube changed.
Required extras
No Cube package is installed for any of it: no Node, no
cube_dbt, no
apache-ossie-cube. Only querying needs a running Cube server, and it uses that
server as a renderer: see below.
Plain YAML is read; nothing else is guessed
Cube renders a YAML model file as a Jinja template before it compiles it, compiles a JavaScript model with Node, and mergesextends in its compiler. None
of that is statically readable, so each is compiled-only:
- a
.jsfile; - a YAML file containing
{{,{%or{#; - a cube or view using
extends, element by element.
notes and in its
compiled_only list (file, reason, and element where only one cube or view
is affected), and nothing is translated out of it. It is never reported as
absent. A join or a reference to a cube the set does not hold is a warning rather
than a refusal while a compiled-only file is configured, because that cube may be
the one the file defines.
What a model file is checked against
Cube publishes no JSON Schema. Its validator is the schema compiler’s Joi rules (CubeValidator.ts), and Dex ports them, pinned to Cube 1.8.14, for every field
it reads:
-
each element. An identifier
nameon every cube, view, member and join. A cube hassqlorsql_table, not both and not neither. Dimension types arestring,number,boolean,time,geoandswitch. Measure types arecount,count_distinct,count_distinct_approx,sum,avg,min,max,number,string,timeandboolean(plusnumber_aggandrankon a multi-stage measure). Checked shapes: joinrelationship, custom granularities,rolling_window, and a view’scubes. Keys are read in either spelling,sql_tableorsqlTable, as Cube camelizes them, and the legacy relationshipsbelongs_to,has_manyandhas_oneread asmany_to_one,one_to_manyandone_to_one. -
the set. Cubes and views share one namespace, so a name declared twice
refuses every element declaring it. Further refusals, each of which also
refuses the element declaring it:
- a join to a cube the set does not hold;
- two joins to one cube without an
alias; - a
{reference}that resolves to nothing; - a view
join_pathhop that no declared join supports; - a view
includesmember the cube does not declare; - a view reaching one cube by two paths from one root.
-
unknown keys. Cube refuses a key its schema does not declare. Reading, Dex
only warns (
unknown_key): a key the port does not know may be one a newer Cube added, and refusing the cube would hide everything else it declares.
cubes['orders'].joins['customers'],
views['sales'].cubes['orders.customers']). One broken file leaves the rest
readable, and the catalog raises only when nothing at all could be read.
References and lineage
A Cubesql is an f-string: {CUBE} (or {TABLE}) is the cube itself,
{member} and {CUBE.member} a member of it, {cube} and {cube.member}
another cube, and {cube1.cube2.member} a member reached through declared joins.
\{ and \} are literal braces. {FILTER_PARAMS...}, {SECURITY_CONTEXT...},
{SQL_UTILS...} and {FILTER_GROUP...} are per-query values: not errors, but an
expression reading one has no single column behind it.
A member’s physical column is traced from its sql. amount,
{CUBE}.amount, and a reference to a member that is itself one column all
resolve. An expression, a quoted identifier, another cube’s member or a
per-query value resolves to nothing. A resolved column is folded the way the
active connector folds an unquoted identifier, as the relation is: on Snowflake,
customer_id is the column CUSTOMER_ID. A cube’s relation depends on how it
reads its table:
sql_table: read with the active connector’s identifier folding.sql: claimed only forSELECT <columns or *> FROM <one table>with no other clause, and its columns are traced through the projection (customer_key AS idmakesid’s columncustomer_key).- a filtered single-table query: keeps its table for the PII gate alone, with a note, because its rows are not the table’s.
- a joined query: no relation at all.
How Cube maps onto the catalog
A metric is groupable by its own cube’s dimensions and by those of every cube its
declared joins reach, in their declared direction, as Cube’s join graph is
directed. A measure name is scoped to its cube in Cube (every cube may declare
count), so a measure is identified by its name and its semantic_model
together. agg_time_dimension and a metric’s time_axis are the cube’s time
dimension where it has exactly one; Cube declares no default, so with several,
none is named and a note says why. public: false and a cube on another
data_source are listed, with a note.
What has no home is declared in unavailable: Cube has no entities, so every
entity field is a gap and keys and joins reach explore through the declaration
channel instead, and a semantic model has no model_ref or primary entity.
dimension_scope is declarations.
What is never read. meta is free-form, and mask and access policies
govern values at query time. None of them reaches the catalog, the declarations
or the fingerprint.
What Cube’s declarations reach in explore
- a sole
primary_keydimension becomes a declared unique key; several become one composite grain in declared order; - a join becomes a declared relationship at confidence 1.0, written child to
parent: the joining cube is the child, except for
one_to_many. A single-pair join is also a foreign key; explore map --use-projectmarks each relation with the cubes on it.
Authoring Cube model files
semantic cube define|update|plan <intent> --edits-file <path|-> authors the
files through the same plan/apply spine as every other edit. Each edit is one
whole file of the semantic_document kind, keyed exactly as semantic.cube.files
names it, and is written byte-for-byte: comments, key spelling (sqlTable or
sql_table), quoting and line endings land as authored.
What can be written. Only the plain YAML files the configuration lists. Each
of these is refused by name, and nothing is stored:
The compiled-only reasons are the strings the catalog writes under
compiled_only, so a refusal and a catalog entry about the same file agree.
What is checked. The whole prospective set (every configured file, with the
edits laid over it) is validated by the rules the reader runs, so a plan that
would leave a file the catalog cannot read is never stored. Authoring adds one
rule: a key Cube does not declare at its level is refused in a file the plan
writes, where the reader only warns, because dex would be writing the defect. It
adds one warning too: a cube on the one side of a join (it declares
one_to_many, or another cube declares many_to_one to it) that sums or counts
and declares no primary_key is fan_out, because Cube has nothing to
deduplicate those measures on when a query crosses the join.
What the plan reports. Each cube and view in an edited file is defined,
updated or unchanged, compared with key spelling and order normalized, so a
whole-file edit that touches one cube does not read as a change to its
neighbours. define refuses an existing name and a removal; update refuses a
new name; plan takes a mix and names any removed definition in a warning.
What apply does. transform apply re-validates the set against the files as
they are now and writes every edited file or none. A file changed after planning
is a conflict, and the plan has to be remade.
Nothing on this path starts Node, calls a Cube server, or opens a warehouse
connection. Authoring is the same whatever the deployment: dex writes files in
git and never writes to Cube Cloud.
Declarations into Cube
A model declaration reaches a Cube layer through the derivation hook, in two ways. A declared dbt mart derives a cube.transform plan (and transform declare) turn a declared mart under transform.marts_paths into a Cube model
file of its own, <dir>/<mart>.yml, where <dir> is the one directory the
configured YAML files share (model/cubes where they share none):
-
sql_tableis thedatabase.schemathe cubes on this project’s other models share, else the relation the compiled manifest names, else the bare table name (as a limitation). -
data_sourceis written only wheresemantic.cube.data_sourceis notdefault. -
Never derived:
- a population as a measure filter or a segment, since the mart’s SQL already enforces it;
- a metric beyond a declared measure (no implicit
count); - a
median(Cube has no such type); - a
semi_additivemeasure (Cube has no measure that sums across entities but not across time); - a second join to a cube already joined (Cube needs an alias, and dex does not invent one).
not_carried. -
Never overwritten or renamed:
- a cube already on the mart’s table makes the mart
skipped; - a cube or view holding the name makes it
failed.
- a cube already on the mart’s table makes the mart
data.semantic_proposal.plan_id; apply both), only where its path
is already in semantic.cube.files. Otherwise it is proposed, with the
dex config set that adds it.
Declarations annotate a cube. semantic cube define|update|plan take
declarations beside (or instead of) edits. Each names a cube the payload or
the layer defines, and is rendered into it field by field: grain to
primary_key, descriptions and the population sentence, a declared join and
measure, the rest in meta.dex. Only that cube’s entry in its file is rewritten.
Every other cube, view and comment keeps its bytes, and the author’s key spelling
is kept. A declaration naming no cube, or naming a view, is refused.
Querying: Cube renders, Dex executes
Cube has no Python SQL renderer: its planner ships only inside a Cube server. Every Cube server, Core or Cloud, answersPOST /cubejs-api/v1/sql with the SQL
a query would run, without running it. Dex uses that the way it uses MetricFlow’s
explain() and SLayer’s dry run: Cube renders, Dex checks the statement and
executes it through its own connector, under the PII gate and the cost guard.
execution is dex on every result. Dex calls /v1/meta and /v1/sql and
nothing else: never /v1/load, and it never hands Cube a warehouse credential.
The server needs none to render, which scripts/setup_cube_dev.sh relies on.
The order
- Connector. DuckDB, Postgres, BigQuery, Snowflake and Redshift, each held to a golden of what Cube Core renders for it. Databricks, ClickHouse and Trino are refused by name.
- PII gate, before anything is sent: every grouped member, the time
dimension a grain buckets, and every member a
--wherefilter names, resolved to the column behind it through the static catalog and screened on its profile, with the name heuristic as the floor. - Grain, against the time dimension’s built-in and custom granularities.
- Translation to Cube’s query JSON, and the data-source check: every cube
the query reaches must sit on
semantic.cube.data_source. - Stale renderer. Every member the query names is compared with the
server’s
/v1/meta?extended=true: present, the same kind, the same aggregation or type, the same SQL and filters, the same custom granularities, and, for a view member, the same cube member behind it. A Cube server outside dev mode does not reload an edited model file, so a server still serving yesterday’s files is the normal state after an edit, and it is refused naming the member. - Render over
/v1/sql, and the plan checks: one statement (acompareDateRangeplans several), no pre-aggregation (Cube matches a rollup even one it never built, and the SQL then reads its table), no external store, no lambda query, the configured data source, and no relation that no cube in the repository’s files sits on (which is also how a server whosesql_tablediffers from the repository’s is caught, since/v1/metadoes not carrysql_table). - Binding. Cube returns
[sql, params], every parameter a string. Each placeholder ($non Postgres and Redshift,?elsewhere) becomes a literal of the connector’s dialect, quoted by that dialect’s rules. A placeholder style or identifier quote that belongs to another warehouse is refused: run the Cube server withCUBEJS_DB_TYPEset to Dex’s connector. - The governed spine: SELECT-only, the relation pre-check against the
connection’s inventory, the cost handshake (
needs_confirmationon a metered connector before any spend), Dex’s connector in a UTC session (the setting Cube’s own drivers use, so a timestamp with no zone buckets the way Cube means), and the columnar cap.
The CLI dialect
A filter Dex cannot read (an unknown operator or key, a value that is not a
string, number or boolean, an empty group) is refused rather than screened by
half. Result columns carry the tokens the caller wrote.
explore semantic values is a dimension-only query rooted at the dimension’s
own cube (scoped_to empty); --metric adds that measure and names it in
scoped_to. A PII dimension is refused before Cube is asked.
The server and its secret
semantic.cube.url names the API base (http://localhost:4000/cubejs-api when
unset). The token is an HS256 JWT Dex mints per request from CUBEJS_API_SECRET,
read from the environment only, valid for 60 seconds, and never written to an
envelope or an error. A loopback host is admitted; any other host only by exact
name in DEX_CUBE_ALLOWED_HOSTS, over https, because the URL comes from a
committed file and the secret is yours. The token claims dex_scopes: [meta, sql]; Cube enforces API scopes only through the deployment’s own
contextToApiScopes, so on a default server the claim restricts nothing, and
what binds is that Dex calls nothing else.
What this release does not do
--api(Cube executes over/v1/load) and Cube Cloud are #914.- One data source per query.
- Segments,
ungrouped,compareDateRangeand pre-aggregations are not sent or not accepted. - Two warehouses that share a placeholder style and quoting (Postgres and
Redshift; DuckDB and Snowflake) cannot be told apart from a statement with no
parameters, so a server rendering for the wrong one of a pair is caught only
once the query carries a filter. Set
CUBEJS_DB_TYPEto match.
What maintain makes of the layer
Every axis runs on a Cube layer, beside a dbt project or with none. No
maintain command starts a Cube server or Node: each reads the configured
model files and the metadata Dex captured through its own connection.
-
Snapshot.
maintain snapshotfingerprints each cube and view, each measure (ascube.member), eachprimary_key(a composite one whole) and each join with its pairs and arity, undervendor: cubeand the validator release. Content hashes cover each element as written, keys normalized,metaexcluded, so respellingsqlTableassql_tableis not a change. -
Compiled-only definitions (a
.jsmodel, a Jinja-templated file, anextends) are recorded by their content underopaque_definitions: a whole file by its bytes, anextendselement by its own body, so editing a sibling cube in the same file does not mark it changed. A change iscompiled_only_changed, what changed is not read, and a warning that these were compared by content alone, never structurally, rides everymaintain semanticandmaintain checkwhile the layer holds one. A layer with one never reads clean. -
Semantic. A cube, view or measure added, removed or changed, with
definition_changedon a cube naming the table it sits on; a dimension, measure, key or join column gone from its table (dangling_reference,broken_relationship). The scanned half re-counts every non-time dimension that is one column (dimension_cardinality_changed). -
Grain. Every
primary_key, a composite one whole (declared_grain_not_unique,key_lost_uniqueness), and every join’srelationship: amany_to_onejoin whose target’s key is not unique, or aone_to_onejoin with either side not unique, isjoin_cardinality_exceeded. Aone_to_manyjoin is checked on its one side. Cube requires arelationship, so every join that loads has one; a join whosesqlis not an equality of columns has no pairs to measure, and is named inwarnings. -
Schema. Cube compares a model with the warehouse only by compiling it
inside a Cube server, so Dex holds the declarations to its own metadata:
An expression Dex cannot read (a per-query value such as
FILTER_PARAMS, a reference into another cube’s table, acase, unparseable SQL) is named inwarningsas unchecked, never passed. -
Reconcile.
maintain reconcilecuts the removals out of the model files, every other byte (other cubes, views and comments) as it was, and stores them as a plan of their own through thesemantic cube updateroute and its validation, underdata.semantic_plan_id. A flow-style list of plain names (includes: [revenue, status]) is rewritten in place with the survivors in their own spelling; anything else that cannot be cut by its lines is advisory. A measure whose filter reads a dropped column goes with it rather than silently losing the filter. Always advisory: a cube whose table is gone (a vanished table is as often a rename as a retirement); a view source whose join path walks a removed join, and the join it walks; asqlcube whose own query selects the dropped column; and every removal in a file that already holds a key Cube does not declare, sincesemantic cube updaterefuses any edit to that file.
references/cube-compatibility.md (edit on main). This page is generated; change the source, not the site.