From datajunction
Helps design DataJunction semantic models: choosing node shapes, decomposing SQL into nodes, and applying naming/ownership/namespace conventions.
How this skill is triggered — by the user, by Claude, or both
Slash command
/datajunction:datajunction-semantic-modelThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
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.
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:
datajunction-api's "Checking if a Namespace Is Repo-Backed" section for how to verify).Not just metrics — every DJ node should declare owners. The reasons apply uniformly across all node types:
Best practices:
owners field empty or omit it✅ GOOD — team ownership:
[email protected]
[email protected]
⚠️ ACCEPTABLE — individual ownership (less sustainable):
[email protected]
❌ BAD — no owners (governance nightmare!)
This applies to sources, transforms, dimensions, metrics, and cubes alike.
Use fully qualified names with namespace:
namespace.node_name
Examples:
finance.total_revenuecommon.dimensions.usersclean.user_eventsrevenue (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.
Namespaces are organized by business area:
Common conventions:
common.dimensions.* — shared dimensions (users, dates, regions)finance.* — financial metrics & factsgrowth.* — user engagement & activationproduct.* — product usage & featuressource.* — raw source tablesA general spirit that runs through every layer:
The semantic layer has two goals working together:
DJ uses normalized star schema modeling. The decisions you make here shape everything downstream.
| 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:
status IN ('complete', 'completed') → 'completed'), joining, or filtering before it represents the entity meaningfully.What does one row in your fact transform represent?
order_id(order_id, snapshot_date)(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.
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.
A metric is one aggregation expression over a single source, transform, or dimension node.
Metrics cannot contain WHERE clauses
-- ❌ 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
Build derived metrics by referencing other metrics in the query. Excellent for ratios, rates, and complex calculations.
-- 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
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:
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:
-- 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.
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):
-- 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):
-- 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:
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):
-- 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:
-- 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).
| 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 |
required_dimensions | ❌ Optional | List of dimension names | For time-based / windowed metrics |
owners | ❌ Optional but strongly recommended | List of email addresses | Prefer team emails |
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).
1. Parse the query mechanically. From the user's SQL, extract:
SUM, COUNT, COUNT DISTINCT, MIN, MAX, AVG,
APPROX_COUNT_DISTINCT, percentile/window aggregates) → each is a
candidate base metric.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:
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.
User's query:
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).
npx claudepluginhub datajunction/dj --plugin datajunctionQueries DataJunction semantic layer: finds nodes, generates SQL, fetches metric data, explores lineage, visualizes results via UI, MCP tools, or APIs.
Build, validate, and manage semantic models using Sidemantic. Creates semantic layers mapping database tables to business dimensions/metrics, generates SQL, and imports from Cube/dbt/LookML.
Guides step-by-step in defining business metrics (aggregations) on Honeydew entities. Covers SQL expression building and deployment via MCP tools.