Transformation
Transformations are the declared steps between the source and the materialized target. Each entry in transformations[] is one operation (Transformation); they run in order. OLC is SQL-first: the sql: op gives you full SQL, and every shorthand op below is a convenience wrapper that compiles to SQL. So each op here shows its SQL variant first — that's what actually runs — followed by the shorthand you can use instead.
Powered by
Transformations are Pydantic models compiled to native operations on the active engine — DataFrame ops / SQL on PySpark, duckdb, or polars. The SQL below is shown DuckDB-flavored for concreteness; the framework rewrites dialect differences (e.g. QUALIFY, DATE_DIFF, PIVOT) per engine, so the same op runs everywhere. In every sql: block, source is the current dataset and links: datasets are joinable by name.
SQL is the portability bet — dialects are a v-next concern
OLC is SQL-native on purpose: sql: "amount > 0" is far more portable across engines than a bespoke { check: greater_than, value: 0 } object, because Spark SQL, DuckDB, Snowflake, BigQuery, and Fabric all understand broadly SQL-shaped expressions. Dialect differences are real but bounded — today the reference framework rewrites the common ones. A future spec version may let a rule pin its dialect explicitly, e.g. expression: { sql: "amount > 0", dialect: ansi }, so authors can opt into a portable ANSI subset or an engine-specific escape hatch. v1 does not try to solve every dialect — write standard-leaning SQL, and let the framework handle the rest.
phase — pre or post
Every op accepts phase: pre | post. Pre transforms run before quality checks and may reference only source columns; post transforms run after the good/bad split and may reference derived columns too. The full run sequence — where pre and post sit relative to validation, quarantine, and materialization — is on the Execution Order page.
SQL-first (the escape hatch)
The most powerful op — full SQL with access to source and any links: reference datasets. Everything below is shorthand for common shapes of this:
- sql: |
SELECT
o.*,
ROUND(o.quantity * o.unit_price * (1.0 - COALESCE(o.discount_pct, 0)), 2) AS line_total,
c.name AS customer_name
FROM source o
LEFT JOIN customers c ON o.customer_id = c.customer_id
phase: post
Column shaping
select — keep only these columns
drop — remove these columns
rename — rename columns
- rename: { from: cust_id, to: customer_id } # single
phase: pre
- rename: # many
mappings: { cust_id: customer_id, email_addr: email, cust_status: status }
phase: pre
cast — change column types
SELECT * REPLACE (
CAST(customer_id AS BIGINT) AS customer_id,
CAST(age AS INTEGER) AS age,
CAST(created_at AS TIMESTAMP) AS created_at
) FROM source
derive — add a computed column
SELECT *,
CASE WHEN age < 25 THEN 'young' WHEN age < 65 THEN 'adult' ELSE 'senior' END AS age_group
FROM source
- derive: { field: age_group, sql: "CASE WHEN age < 25 THEN 'young' WHEN age < 65 THEN 'adult' ELSE 'senior' END" }
phase: post
# optional engine-specific overrides: sql_duckdb / sql_spark
coalesce — first non-null across columns
SELECT *,
COALESCE(primary_email, secondary_email, backup_email, 'unknown@example.com') AS email
FROM source
- coalesce: { field: email, sources: [primary_email, secondary_email, backup_email], default: "unknown@example.com", output: email }
phase: pre
map_values — remap values via a lookup dict
SELECT *,
CASE status WHEN 'A' THEN 'ACTIVE' WHEN 'I' THEN 'INACTIVE' WHEN 'P' THEN 'PENDING' ELSE 'UNKNOWN' END AS status
FROM source
- map_values: { field: status, mapping: { A: ACTIVE, I: INACTIVE, P: PENDING }, default: UNKNOWN, output: status }
phase: pre
String helpers
trim — strip whitespace
lower / upper — case-fold columns
SELECT *, LOWER(email) AS email, LOWER(status) AS status FROM source
SELECT *, UPPER(country_code) AS country_code FROM source
split — split a string into parts
json_extract — pull a field out of JSON/struct
SELECT *, CAST(json_extract_string(location_coordinates, '$.latitude') AS FLOAT) AS latitude FROM source
- json_extract: { field: latitude, source: location_coordinates, path: "$.latitude", cast: float }
phase: post
Rows & reshaping
filter — keep rows matching a predicate
deduplicate — drop duplicates, keep the chosen row
SELECT * FROM source
QUALIFY ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC) = 1
- deduplicate: { "on": [customer_id], sort_by: [updated_at], order: desc } # quote 'on' (YAML keyword)
phase: pre
sort_by is required. A deduplicate discards rows, so which duplicate survives
is a business decision the contract must state; without it an implementation has to
invent a survivor, and different engines invent different ones — the same contract
would then produce different tables on different platforms. A contract omitting it
is refused (conformance case OLC-T-002).
Order by the column that identifies the newest record version (updated_at), not
a business-event column: duplicates are usually re-deliveries of one row, and the
event columns are identical across them.
Deprecated:
deduplicate_by_latest: { key_columns, timestamp_column }is exactlydeduplicatewithsort_by: [timestamp_column], order: desc. It adds no expressiveness — it cannot express "keep the earliest" or a multi-column tie-break — and its separatetimestamp_columnspelling is silently ignored if written ondeduplicate. It still parses for contracts already published; usededuplicatein new work.
explode — one row per array element
date_range_explode — expand a [start, end] range into daily rows
SELECT *,
UNNEST(generate_series(creation_date, COALESCE(deleted_at, current_date), INTERVAL 1 DAY)) AS snapshot_date
FROM source
- date_range_explode: { output: snapshot_date, start_col: creation_date, end_col: deleted_at, interval: 1d }
phase: post
bucket — assign rows to numeric bands
SELECT *,
CASE WHEN listing_price < 250000 THEN 'sub_250k'
WHEN listing_price < 500000 THEN '250k_500k'
WHEN listing_price < 1000000 THEN '500k_1m'
ELSE '1m_plus' END AS price_band
FROM source
- bucket:
field: price_band
source: listing_price
bins:
- { lt: 250000, label: sub_250k }
- { lt: 500000, label: 250k_500k }
- { lt: 1000000, label: 500k_1m }
default: 1m_plus # bin bounds: lt / lte / gt / gte / eq
phase: post
date_diff — difference between two dates
- date_diff: { field: listing_age_days, from_col: creation_date, to_col: event_date, unit: days } # days | hours | months
phase: post
Joins & lookups
lookup — one field from a reference table
SELECT s.*, COALESCE(d.name, 'Unknown') AS country_name
FROM source s
LEFT JOIN dim_countries d ON s.country_code = d.code
- lookup: { field: country_name, reference: dim_countries, "on": country_code, key: code, value: name, default_value: Unknown }
phase: post
join — multiple fields from a reference table
SELECT s.*,
COALESCE(d.product_name, 'Unknown Product') AS product_product_name,
COALESCE(d.category, 'Uncategorized') AS product_category,
d.price AS product_price
FROM source s
LEFT JOIN dim_products d ON s.product_id = d.id
- join:
reference: dim_products
"on": product_id
key: id
fields: [product_name, category, price]
type: left # left | inner | right | full
prefix: "product_"
defaults: { product_name: Unknown Product, category: Uncategorized }
phase: post
Aggregation & pivots
rollup — group-by aggregation
SELECT customer_segment, country,
COUNT(*) AS total_customers,
AVG(age) AS avg_age,
SUM(lifetime_value) AS total_revenue
FROM source
GROUP BY customer_segment, country
- rollup:
group_by: [customer_segment, country]
aggregations: { total_customers: "COUNT(*)", avg_age: "AVG(age)", total_revenue: "SUM(lifetime_value)" }
keys: customer_id # track rollup lineage (which rows rolled up)
distinct: true
phase: post
pivot — long → wide
- pivot: { id_vars: [customer_id], pivot_col: metric, value_cols: [value], values: [clicks, impressions], agg: sum, name_template: "{pivot_alias}" }
phase: post
unpivot — wide → long
- unpivot: { id_vars: [customer_id], value_vars: [clicks, impressions], key_field: metric, value_field: value, include_nulls: false }
phase: post
Full op list — SQL variant + shorthand
| Op | SQL variant (what runs) | Shorthand |
|---|---|---|
select |
SELECT a, b FROM source |
select: { columns: [a, b] } |
drop |
SELECT * EXCLUDE (tmp) FROM source |
drop: { columns: [tmp] } |
rename |
SELECT * RENAME (a AS b) FROM source |
rename: { from: a, to: b } |
cast |
SELECT * REPLACE (CAST(age AS INT) AS age) FROM source |
cast: { columns: { age: int } } |
derive |
SELECT *, a + b AS t FROM source |
derive: { field: t, sql: "a + b" } |
coalesce |
SELECT *, COALESCE(e1, e2) AS e FROM source |
coalesce: { field: e, sources: [e1, e2], output: e } |
map_values |
SELECT *, CASE s WHEN 'A' THEN 'ACTIVE' END AS s FROM source |
map_values: { field: s, mapping: { A: ACTIVE }, output: s } |
trim |
SELECT *, TRIM(e) AS e FROM source |
trim: { fields: [e], side: both } |
lower |
SELECT *, LOWER(e) AS e FROM source |
lower: { fields: [e] } |
upper |
SELECT *, UPPER(cc) AS cc FROM source |
upper: { fields: [cc] } |
split |
SELECT *, STRING_SPLIT(tags, ',') AS arr FROM source |
split: { field: tags, delimiter: ",", output: arr } |
json_extract |
SELECT *, CAST(json_extract_string(loc, '$.lat') AS FLOAT) AS lat FROM source |
json_extract: { field: lat, source: loc, path: "$.lat", cast: float } |
filter |
SELECT * FROM source WHERE id IS NOT NULL |
filter: { sql: "id IS NOT NULL" } |
deduplicate |
... QUALIFY ROW_NUMBER() OVER (PARTITION BY id ORDER BY ts DESC) = 1 |
deduplicate: { "on": [id], sort_by: [ts], order: desc } |
explode |
SELECT *, UNNEST(arr) AS tag FROM source |
explode: { field: arr, output: tag } |
date_range_explode |
SELECT *, UNNEST(generate_series(s, e, INTERVAL 1 DAY)) AS d FROM source |
date_range_explode: { output: d, start_col: s, end_col: e, interval: 1d } |
bucket |
SELECT *, CASE WHEN price < 100 THEN 'lo' ELSE 'hi' END AS band FROM source |
bucket: { field: band, source: price, bins: [{lt: 100, label: lo}], default: hi } |
date_diff |
SELECT *, DATE_DIFF('day', a, b) AS age FROM source |
date_diff: { field: age, from_col: a, to_col: b, unit: days } |
lookup |
SELECT s.*, d.name FROM source s LEFT JOIN dim d ON s.code = d.code |
lookup: { field: name, reference: dim, "on": code, key: code, value: name } |
join |
SELECT s.*, d.x FROM source s LEFT JOIN dim d ON s.id = d.id |
join: { reference: dim, "on": id, key: id, fields: [x], type: left } |
rollup |
SELECT g, COUNT(*) AS n FROM source GROUP BY g |
rollup: { group_by: [g], aggregations: { n: "COUNT(*)" } } |
pivot |
PIVOT source ON m USING sum(v) GROUP BY id |
pivot: { id_vars: [id], pivot_col: m, value_cols: [v], values: [a, b], agg: sum } |
unpivot |
UNPIVOT source ON a, b INTO NAME m VALUE v |
unpivot: { id_vars: [id], value_vars: [a, b], key_field: m, value_field: v } |
sql |
(any SQL you write) | sql: "SELECT *, a+b AS c FROM source" |
Shorthand or SQL — same result
The shorthand ops are portable, introspectable, and agent-friendly (an agent can add a rename and know exactly what changed). The sql: op is the full-power escape hatch. Because the shorthands compile to the SQL shown above, you can start with shorthand and drop to sql: the moment a case gets bespoke — no rewrite of the rest.