---
name: datajunction-semantic-model
description: |
  Activate this skill for DataJunction (DJ) semantic modeling decisions —
  choosing the right node shape (fact, dimension, transform, metric, cube),
  turning a draft SQL query into well-designed nodes, and the cross-cutting
  conventions (ownership, naming, namespace organization). Format-agnostic
  modeling guidance.
  Keywords:
  - semantic modeling
  - decompose query, model query, query to nodes
  - how should I model this metric, what shape should this node be
  - design a cube, what belongs in this cube
  - ratio metric, derived metric, base metric
  - composable metrics
  - metric query constraints
  - node ownership, metric ownership
  - metric naming, namespace organization
  - grain, fact vs dimension
  - dimension link, not JOIN
user-invocable: false
---

# DataJunction Semantic Modeling

Use this skill when designing how something gets expressed as DJ nodes — independent of whether you'll write the result as YAML files in a repo or POST it to the DJ API.

For DJ vocabulary (what node types exist, how dimension links work mechanically), see the `datajunction` skill. This skill assumes that vocabulary and focuses on the modeling *decisions*.

---

## General principles

### Reuse over reinvent

Before authoring a new node, check whether an authoritative one already exists. Use the `datajunction-query` skill's MCP tools (`search_nodes`, `get_node_details`) to find candidates. Every additional transform / dim / metric fragments the catalog; every reuse strengthens it.

When you can't find a fit:
- **Build on the closest existing parent** rather than creating a sibling source/transform that duplicates upstream logic.
- **Refuse to build on non-git-backed namespaces.** Nodes without a source repo can change or disappear without review — depending on them silently couples your work to someone's UI session. Stick to git-backed namespaces (see `datajunction-api`'s "Checking if a Namespace Is Repo-Backed" section for how to verify).

### Every node has owners

Not just metrics — every DJ node should declare owners. The reasons apply uniformly across all node types:
- No one to contact when the node breaks or produces unexpected values
- Undefined responsibility for keeping definitions accurate
- Difficult to coordinate changes or deprecations
- Team knowledge scattered across individuals

**Best practices:**
- ✅ **Prefer team emails over individuals** — teams outlast individual contributors
- ✅ Use Google groups or distribution lists for teams
- ✅ Include both data team AND business stakeholder teams where applicable
- ❌ Never leave `owners` field empty or omit it

```
✅ GOOD — team ownership:
  data-platform-team@company.com
  finance-analytics@company.com

⚠️ ACCEPTABLE — individual ownership (less sustainable):
  alice@company.com

❌ BAD — no owners (governance nightmare!)
```

This applies to **sources, transforms, dimensions, metrics, and cubes** alike.

### Naming conventions

Use fully qualified names with namespace:

```
namespace.node_name
```

**Examples:**
- ✅ `finance.total_revenue`
- ✅ `common.dimensions.users`
- ✅ `clean.user_events`
- ❌ `revenue` (missing namespace)

Names should be **readable and business-meaningful** — what a stakeholder would call this thing, not the column transformations behind it. `total_revenue` over `sum_amount_usd`. `avg_session_duration_secs` over `avg_session_dur`.

### Namespace organization

Namespaces are organized by business area:

**Common conventions:**
- `common.dimensions.*` — shared dimensions (users, dates, regions)
- `finance.*` — financial metrics & facts
- `growth.*` — user engagement & activation
- `product.*` — product usage & features
- `source.*` — raw source tables

### Model named, reusable entities — not anonymous SQL

A general spirit that runs through every layer:
- Don't bury an aggregate inside a derived metric's query when it should be its own named base metric (see the **Metrics** section below for the reusability rule).
- Don't bury a join condition inside a query when it should be a dimension link (see "Joins → dimension links" below).

The semantic layer has two goals working together:
- **Express the business in business concepts.** Stakeholders should see *signups*, *revenue*, *active customers* — not a chain of tables and SQL transformations. Every node should have a name and meaning a non-engineer would recognize.
- **Make every meaningful thing findable, queryable, and reusable.** Anonymous SQL — inline aggregates, hardcoded joins — works against both goals: it hides business concepts behind technical detail and forecloses reuse.

---

## The dimensional model

DJ uses normalized star schema modeling. The decisions you make here shape everything downstream.

### Facts vs dimensions vs transforms

| Node | Used for | Examples |
|---|---|---|
| **Source** | Physical table in the warehouse | `warehouse.finance.transactions_table` |
| **Dimension** | An entity with attributes you'll slice by | `users`, `products`, `dates`, `regions`, `geo_country` |
| **Transform** | Cleaned/derived fact data — the aggregable rows | `clean_transactions`, `daily_user_activity`, `enriched_orders` |
| **Metric** | One aggregation expression over a transform | `total_revenue`, `num_orders`, `avg_session_duration` |
| **Cube** | A curated set of metrics + dimensions for downstream consumers | `revenue_dashboard`, `weekly_orders_report` |

**When to author a transform vs use a source directly:**

- Use the source directly when its row shape *is* the semantic entity you want to aggregate.
- Author a transform when the source needs cleaning (`status IN ('complete', 'completed')` → `'completed'`), joining, or filtering before it represents the entity meaningfully.
- The transform is where you attach dimension links — that determines what slices are available to every metric built on it.

### Grain is the most important decision in a fact

What does *one row* in your fact transform represent?
- "One row per order" → grain is `order_id`
- "One row per order per snapshot day" → grain is `(order_id, snapshot_date)`
- "One row per customer per month" → grain is `(customer_id, month)`

Grains don't mix in one fact. If you find yourself with two different grains in one transform, you have two facts trying to live in one node — split them.

### Joins → dimension links, not baked-in JOINs

The DJ idiom: every JOIN that connects a fact to a dimension belongs in a `dimension_links:` declaration on the fact's transform — *not* inside a metric's query.

Why: dimension links make the join *optional*. Consumers slice by that dim only when they ask for it. A hardcoded JOIN in a metric forces every query that touches the fact to pay the join cost even when nobody's slicing by that dim. It also fragments behavior — metrics on the same fact would each carry their own copy of the same JOIN, with the inevitable drift.

Same applies to dim-to-dim joins (e.g., `users → countries → regions`) — express them as dimension links on the dimension node, creating a dimensional graph DJ can traverse automatically.

See the `datajunction` skill's "Dimension Links" section for the mechanics; this is about *when* to use them — which is essentially always.

---

## Metrics

A metric is **one aggregation expression** over a single source, transform, or dimension node.

### Metric query constraints

**Metrics cannot contain WHERE clauses**
- Use CASE WHEN for conditional aggregation instead

```sql
-- ❌ NOT ALLOWED — WHERE clause in metric
SELECT SUM(amount_usd)
FROM finance.transactions
WHERE status = 'completed'

-- ✅ CORRECT — use CASE WHEN
SELECT SUM(
  CASE WHEN status = 'completed' THEN amount_usd ELSE 0 END
) FROM finance.transactions
```

A `WHERE` permanently constrains scope; `CASE WHEN` lets the metric coexist with a broader-population sibling on the same fact. Reflect the scope in the metric name (`active_signups`, not `signups`).

**Metrics select a single expression from a single node**
- Cannot join multiple nodes in a metric query
- Define joins via dimension links on the source/transform node instead

### Metrics can reference other metrics (composition)

Build derived metrics by referencing other metrics in the query. Excellent for ratios, rates, and complex calculations.

```sql
-- Create base metrics first
SELECT COUNT(*) FROM finance.transactions              -- metric: finance.transaction_count
SELECT SUM(amount_usd) FROM finance.transactions       -- metric: finance.total_revenue

-- Then compose them into a derived metric
SELECT finance.total_revenue / finance.transaction_count  -- metric: finance.avg_transaction_value
-- DJ handles divide-by-zero automatically; NULLIF() is optional safety
```

### The reusability rule: every aggregate is its own named metric

This is the highest-leverage discipline in DJ metric modeling. Apply it even when "the user only cares about the final ratio."

❌ Less reusable — anonymous SQL blob:
```sql
SELECT SUM(amount_usd)
       / NULLIF(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)
FROM finance.transactions
```

✅ Reusable — three named, discoverable, composable metrics:
```sql
-- metric: total_revenue
SELECT SUM(amount_usd) FROM finance.transactions

-- metric: num_completed_orders
SELECT SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END)
FROM finance.transactions

-- metric: avg_order_value (derived)
SELECT total_revenue / NULLIF(num_completed_orders, 0)
```

Why: the bottom shape gives three named metrics anyone can find, query, and reuse. The top shape gives one — the base aggregates are anonymous SQL blobs nobody else can reference. Different teams asking "what's our total revenue?" can't find it; they'd have to know to read inside `avg_order_value`'s query.

### Metric patterns

Common shapes a metric can take. Each shows the `query:` expression — the actual file/API format lives in `datajunction-repo` or `datajunction-api`.

**Base metrics (simple aggregations):**
```sql
-- COUNT
SELECT COUNT(transaction_id) FROM finance.transactions

-- COUNT DISTINCT
SELECT COUNT(DISTINCT customer_id) FROM finance.transactions

-- APPROX_COUNT_DISTINCT (HyperLogLog — for large datasets)
SELECT APPROX_COUNT_DISTINCT(profile_id) FROM finance.transactions

-- SUM
SELECT SUM(amount_usd) FROM finance.transactions

-- AVG
SELECT AVG(amount_usd) FROM finance.transactions

-- Conditional aggregation
SELECT SUM(
  CASE
    WHEN status = 'completed' AND refund_flag = false
    THEN amount_usd
    ELSE 0
  END
) FROM finance.transactions
```

**Derived metrics (composed from base metrics):**
```sql
-- ratio
SELECT finance.total_revenue / NULLIF(finance.transaction_count, 0)

-- rate as percentage
SELECT finance.clicks * 100.0 / NULLIF(finance.impressions, 0)

-- revenue per thousand impressions (RPM)
SELECT finance.total_revenue / NULLIF(finance.impressions, 0) * 1000
```

**Statistical metrics:**
```sql
SELECT VAR_POP(amount_usd) FROM finance.transactions
SELECT STDDEV_POP(amount_usd) FROM finance.transactions
SELECT PERCENTILE_APPROX(amount_usd, 0.5)  FROM finance.transactions  -- median
SELECT PERCENTILE_APPROX(amount_usd, 0.95) FROM finance.transactions  -- p95
```

**Rolling window metrics** (set `required_dimensions` to include the ORDER BY dim):
```sql
-- trailing 7-day sum
SELECT SUM(finance.daily_revenue) OVER (
  ORDER BY common.dimensions.date.dateint
  ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)
```

**Period-over-period metrics:**
```sql
-- week-over-week % change
SELECT
  (finance.weekly_revenue - LAG(finance.weekly_revenue, 1) OVER (
    ORDER BY common.dimensions.date.week_code
  )) * 100.0 /
  LAG(finance.weekly_revenue, 1) OVER (
    ORDER BY common.dimensions.date.week_code
  )
```

Same shape for MoM (`month_code`), QoQ (`quarter_code`), YoY (`year`).

### Metric metadata quick reference

| Field | Required | Valid Values | Notes |
|-------|----------|--------------|-------|
| `name` | ✅ Yes | `namespace.metric_name` | Fully qualified name |
| `query` | ✅ Yes | SQL SELECT expression | Single aggregation from single node |
| `description` | ❌ Optional | String | Recommended for clarity |
| `direction` | ❌ Optional | `higher_is_better` / `lower_is_better` / `neutral` | Indicates performance direction |
| `unit` | ❌ Optional | `dollar` / `unitless` / **⚠️ NOT `count`** | Server rejects `count` — use `unitless` |
| `mode` | ❌ Optional | `draft` / `published` | Default: `published` |
| `fixed_grain` | ❌ Optional | List of dimension names | Grain the aggregate is computed at; omit for query grain, `[]` for global |
| `required_dimensions` | ❌ Optional | List of dimension names | For time-based / windowed metrics |
| `owners` | ❌ Optional but strongly recommended | List of email addresses | Prefer team emails |

### Key patterns summary

- **DJ handles divide-by-zero automatically** — NULLIF() is optional safety
- **Use CASE WHEN** instead of WHERE clauses for filtering
- **Window functions** enable rolling windows and period-over-period comparisons
- **required_dimensions** should include the dimension used in window ORDER BY clauses
- **Derived metrics** can reference other metrics for ratios and calculations
- **Always specify owners** — use team emails for sustainability

---

## Query → DJ decomposition workflow

When a user arrives with an existing SQL query and wants to express it as DJ nodes, **don't generate node definitions on the first pass**. Propose a structured decomposition, get critique, iterate on the shape, then have the user create the nodes (in YAML via `datajunction-repo`, or via API via `datajunction-api`).

### The workflow

**1. Parse the query mechanically.** From the user's SQL, extract:
- **Aggregates** (`SUM`, `COUNT`, `COUNT DISTINCT`, `MIN`, `MAX`, `AVG`,
  `APPROX_COUNT_DISTINCT`, percentile/window aggregates) → each is a
  **candidate base metric**.
- **FROM / JOIN tables** → candidate parent nodes (transforms or dimensions).
- **GROUP BY columns, JOIN-bound columns, top-level WHERE columns** →
  candidate **dimension link** targets.
- **Top-level SELECT expressions that combine the aggregates** (ratios,
  sums-of-sums, computed expressions) → candidate **derived metrics**.

**2. Resolve parents against existing nodes first.** For each candidate parent table or dimension, use the `datajunction-query` skill's MCP tools (`search_nodes`, `get_node_details`) to check whether DJ already has an authoritative node for it. Prefer building on existing nodes — every additional transform fragments the catalog.

**3. Treat every JOIN as a candidate dimension link, not a baked-in join.** See "Joins → dimension links" above. The DJ idiom is to express joins as `dimension_links:` on the fact's transform, *not* hardcoded in the metric query.

**4. Apply the reusability rule.** Even if the user's query has aggregates *only* inside a ratio expression, decompose them: one named base metric per aggregate, then a derived metric for the ratio.

**5. Handle WHERE clauses correctly.** A `WHERE` in the user's query is one of two things:
- A **dimensional filter** the consumer should apply at query time → keep it out of the metric definition entirely.
- **Part of the metric's identity** → bake it into the aggregate as `CASE WHEN`, not as a `WHERE` on the metric's query.

**6. Name things meaningfully.** Propose readable, business-meaningful names that match what a stakeholder would call the metric or entity.

**7. Check for duplicates before producing nodes.** For each proposed metric / transform / dimension name, check whether something with the same name (or doing the same thing under a different name) already exists in the catalog. Reuse rather than recreate; rename when the names collide but the semantics differ.

**8. Propose, don't produce.** Present the decomposition as a structured list (parents, base metrics, derived metrics, dim links) and ask the user to critique the shape. Only after they confirm, hand off to `datajunction-repo` (for YAML) or `datajunction-api` (for curl) to produce the actual node definitions.

### Worked example: ratio decomposition

User's query:

```sql
SELECT
  region_id,
  SUM(amount_usd)
  / SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS avg_order_value
FROM finance.transactions
GROUP BY region_id
```

Decomposition:

```
Parent transform:
  Reuse: finance.transactions  (existing)

Base metrics (one per aggregate):
  1. total_revenue          = SUM(amount_usd)
  2. num_completed_orders   = SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END)

Derived metric:
  1. avg_order_value = total_revenue / NULLIF(num_completed_orders, 0)

Dimension links:
  - region_id → common.dimensions.region  (existing shared dim)
```

Why this shape is worth the extra metric nodes: downstream tools re-aggregate the materialized output. Three independent metrics roll up correctly across any dimensional slice — sum the numerator, sum the denominator, then divide. A single pre-divided ratio doesn't compose: when consumers re-aggregate, the numerator and denominator can drift apart across slices in subtle ways (especially when NULL rows are dropped from one but not the other).
