EA Mapping, Reimagined — Part 2- A Practical Demo with DuckDB & Python
From documentation to execution: generating SQL from business‑friendly mappings stored as metadata.

What You’ll Build
- A compact metadata schema in DuckDB for sources, targets, and mappings.
- A mapping logic mini-language (plain text) with
copy,const,concat,coalesce, andlookupoperations. - A Python generator that reads mappings and emits SQL to materialize a target view/table.
- Lightweight lineage queries and validation checks.
This demo is tool-agnostic. You can drop the SQL into Databricks, run it via Airflow, or compile it into dbt models.
1) DuckDB Metadata Schema
We’ll treat metadata as first-class data.
CREATE SCHEMA IF NOT EXISTS meta;
-- Canonical registry of datasets (schemas, tables, and business names)
CREATE TABLE IF NOT EXISTS meta.dataset (
dataset_id INTEGER PRIMARY KEY,
layer TEXT, -- source|integration|exposure
system_name TEXT, -- e.g. crm, sap, s3_raw
schema_name TEXT,
table_name TEXT,
business_name TEXT, -- friendly label
is_active BOOLEAN DEFAULT TRUE
);
-- Column definitions per dataset
CREATE TABLE IF NOT EXISTS meta.column (
dataset_id INTEGER,
column_name TEXT,
data_type TEXT,
business_def TEXT,
PRIMARY KEY (dataset_id, column_name)
);
-- Mapping units: each row maps one target column using a logic expression
CREATE TABLE IF NOT EXISTS meta.mapping (
mapping_id INTEGER PRIMARY KEY,
target_ds_id INTEGER,
target_col TEXT,
logic_kind TEXT, -- copy|const|concat|coalesce|lookup|sql
logic_payload TEXT, -- JSON or simple text (see below)
precedence INTEGER DEFAULT 100, -- combine rules if multiple rows target same column
note TEXT
);
-- Optional: reference lookups (for code->meaning or surrogate keys)
CREATE TABLE IF NOT EXISTS meta.lookup (
lookup_name TEXT,
key TEXT,
value TEXT
);
Logic conventions
copy:logic_payload = "source_ds:crm.customers, col:email"const:logic_payload = "value:EUROPE"concat:logic_payload = "[ {source:'crm.customers', col:'first_name'}, {const:' '}, {source:'crm.customers', col:'last_name'} ]"coalesce:logic_payload = "[ {source:'sap.clients', col:'email'}, {source:'crm.customers', col:'email'} ]"lookup:logic_payload = "{name:'country_codes', from:'country_code'}"(applies to a previously resolved expression)sql: raw SQL fragment when you need total control.
Store logic_payload as JSON strings for safety and parsing.
2) Seed Example Metadata
-- Datasets
INSERT INTO meta.dataset VALUES
(1,'source','crm','public','customers','CRM Customers',TRUE),
(2,'source','sap','public','clients','SAP Clients',TRUE),
(3,'integration','core','public','party','Canonical Party',TRUE);
-- Columns (subset)
INSERT INTO meta.column VALUES
(1,'customer_id','INTEGER','CRM technical id'),
(1,'first_name','TEXT','Given name'),
(1,'last_name','TEXT','Surname'),
(1,'email','TEXT','Email address'),
(2,'client_id','INTEGER','SAP technical id'),
(2,'mail','TEXT','Client email'),
(2,'country_code','TEXT','ISO alpha-2 code'),
(3,'party_id','INTEGER','Surrogate id'),
(3,'full_name','TEXT','Full legal name'),
(3,'email','TEXT','Preferred email'),
(3,'country','TEXT','Country name');
-- Lookups
INSERT INTO meta.lookup VALUES
('country_codes','NL','Netherlands'),
('country_codes','DE','Germany'),
('country_codes','BE','Belgium');
-- Mapping rules for integration.core.party
INSERT INTO meta.mapping (mapping_id,target_ds_id,target_col,logic_kind,logic_payload,precedence,note) VALUES
(1001,3,'full_name','concat','[ {"source":"crm.public.customers","col":"first_name"}, {"const":" "}, {"source":"crm.public.customers","col":"last_name"} ]',100,'Full name from CRM'),
(1002,3,'email','coalesce','[ {"source":"sap.public.clients","col":"mail"}, {"source":"crm.public.customers","col":"email"} ]',100,'Prefer SAP mail'),
(1003,3,'country','lookup','{"name":"country_codes","from":"country_code","source":"sap.public.clients"}',100,'Resolve ISO code to name');
In a real project you’d ingest these rows from Notion or CSV.
3) Python Generator (reads metadata → emits SQL)
Below is a compact reference implementation. It:
- Pulls mapping rows for a target dataset.
- Builds source CTEs.
- Resolves each
logic_kindinto a SQL expression. - Produces a final
CREATE OR REPLACE VIEWstatement.
import json
import duckdb
con = duckdb.connect()
TARGET_DATASET = 3 # core.public.party
# helper to render a source reference "schema.table.col"
def ref(expr):
src = expr["source"]
col = expr["col"]
# quote identifiers minimally
return f"\"{src}\".\"{col}\"" if "." in src else f"{src}.\"{col}\""
rows = con.execute(
"""
SELECT target_col, logic_kind, logic_payload, precedence
FROM meta.mapping
WHERE target_ds_id = ?
ORDER BY target_col, precedence
""",
[TARGET_DATASET]
).fetchall()
# Group by target column
from collections import defaultdict
by_target = defaultdict(list)
for col, kind, payload, prec in rows:
by_target[col].append((kind, payload))
expressions = {}
for target_col, items in by_target.items():
parts = []
for kind, payload in items:
if kind == 'copy':
d = json.loads(payload)
parts.append(ref(d))
elif kind == 'const':
d = json.loads(payload)
parts.append(f"'{d['value']}'")
elif kind == 'concat':
arr = json.loads(payload)
concat_parts = []
for e in arr:
if 'const' in e:
concat_parts.append(f"'{e['const']}'")
else:
concat_parts.append(ref(e))
parts.append(" || ".join(concat_parts))
elif kind == 'coalesce':
arr = json.loads(payload)
coalesce_parts = []
for e in arr:
coalesce_parts.append(ref(e))
parts.append(f"COALESCE({', '.join(coalesce_parts)})")
elif kind == 'lookup':
d = json.loads(payload)
# simple CASE WHEN lookup using meta.lookup
# note: for production, join a dim table instead
parts.append(
f"(SELECT l.value FROM meta.lookup l WHERE l.lookup_name='{d['name']}' AND l.key = {ref({'source': d['source'], 'col': d['from']})} )"
)
elif kind == 'sql':
parts.append(payload)
# If multiple items per column, wrap last one; precedence allows overrides
expr = parts[-1]
expressions[target_col] = expr
# Build FROM sources set by scanning payloads
sources = set()
for items in by_target.values():
for kind, payload in items:
if kind in ('concat','coalesce'):
for e in json.loads(payload):
if 'source' in e:
sources.add(e['source'])
elif kind in ('copy','lookup'):
d = json.loads(payload)
if kind == 'copy':
sources.add(d['source'])
if kind == 'lookup':
sources.add(d['source'])
# naive join list (cross join placeholder). Replace with real join conditions config
from_clause = "\n ,\n ".join(sorted(sources))
select_list = ",\n ".join([f"{expr} AS {col}" for col, expr in expressions.items()])
sql = f"""
CREATE OR REPLACE VIEW core.public.party AS
WITH
-- Source staging (replace with real filters or SCD2 as needed)
src AS (
SELECT * FROM {sorted(sources)[0]}
)
SELECT
{select_list}
FROM
{from_clause};
"""
print(sql)
This generator is intentionally minimal to keep the idea clear. In production you’ll configure joins, SCD2 filters, and null-handling contracts.
4) Example Output (Generated SQL)
For the seeded metadata, a simplified output looks like:
CREATE OR REPLACE VIEW core.public.party AS
SELECT
"crm.public.customers"."first_name" || ' ' || "crm.public.customers"."last_name" AS full_name,
COALESCE("sap.public.clients"."mail", "crm.public.customers"."email") AS email,
(SELECT l.value FROM meta.lookup l WHERE l.lookup_name='country_codes' AND l.key = "sap.public.clients"."country_code") AS country
FROM
crm.public.customers,
sap.public.clients;
You’ll replace the implicit cross-join with real join conditions sourced from metadata (e.g., join_key table), or drive it via your Source Access Views.
5) Validation & Lineage Queries
Because everything is metadata, you can add QA with plain SQL.
Find unmapped target columns
SELECT c.column_name
FROM meta.column c
LEFT JOIN (
SELECT DISTINCT target_col FROM meta.mapping WHERE target_ds_id = 3
) m ON m.target_col = c.column_name
WHERE c.dataset_id = 3 AND m.target_col IS NULL;
Column-level lineage
SELECT m.target_col,
m.logic_kind,
m.logic_payload
FROM meta.mapping m
WHERE m.target_ds_id = 3
ORDER BY m.target_col, m.precedence;
6) Extending the Demo
- Joins as metadata: add
meta.joinwithleft_ds_id,right_ds_id,condition_sql,join_typeand have the generator render proper joins. - Historization (Dual SCD2): add
valid_from,valid_to, andis_currentcolumns in source views; include timepoint filters parameterized in the generator. - Policies & PII: tag
meta.columnwithpii_leveland enforce masking logic in generation. - dbt integration: render
model.sqlandschema.ymlper target. - Notion front-end: maintain mapping rows in a Notion database; a sync job writes into
meta.*tables.
6a) Dual SCD2 Support (Time-Point & PIT)
Below we extend the metadata and generator to honor Dual SCD2 with time-point filters and optional PIT helpers.
Additional metadata
-- Joins between sources (keyed via SQL condition for flexibility)
CREATE TABLE IF NOT EXISTS meta.join (
left_ds_id INTEGER,
right_ds_id INTEGER,
join_type TEXT DEFAULT 'LEFT',
condition_sql TEXT
);
-- SCD2 contracts per dataset (applies to its Source Access View)
CREATE TABLE IF NOT EXISTS meta.scd2_contract (
dataset_id INTEGER,
valid_from_col TEXT DEFAULT 'valid_from',
valid_to_col TEXT DEFAULT 'valid_to',
is_current_col TEXT DEFAULT 'is_current'
);
-- Optional PIT helper (point-in-time tables) registry
CREATE TABLE IF NOT EXISTS meta.pit (
pit_name TEXT,
dataset_id INTEGER,
key_cols TEXT, -- JSON array of business keys
asof_col TEXT -- reference time column in PIT table
);
Time-point filter pattern
When AS_OF is provided, sources with SCD2 contracts are filtered as:
AS_OF BETWEEN valid_from AND COALESCE(valid_to, TIMESTAMP ‘9999-12-31 23:59:59’)
If a PIT entry exists for a dataset, join the PIT instead of filtering the base view.
Generator delta (Python)
AS_OF = "2025-01-01 00:00:00" # parameterize per run
Fetch SCD2 contracts and joins
scd2 = { (ds,): (vf, vt, ic) for ds, vf, vt, ic in con.execute(
"SELECT dataset_id, valid_from_col, valid_to_col, is_current_col FROM meta.scd2_contract"
).fetchall() }
joins = con.execute(
"SELECT left_ds_id, right_ds_id, join_type, condition_sql FROM meta.join"
).fetchall()
# Build FROM with joins rather than cross product
# Map dataset_id->fully qualified name
name_map = { r[0]: f"{r[2]}.{r[3]}.{r[4]}" for r in con.execute(
"SELECT dataset_id, layer, system_name, schema_name, table_name FROM meta.dataset"
).fetchall() }
# Gather datasets needed from mapping payloads as before -> `needed`
needed = set()
# ... (populate from payloads)
# Build source clauses with SCD2 filters
source_ctes = []
for ds_name in sorted(needed):
# invert name_map to find dataset_id by name
ds_id = [k for k,v in name_map.items() if v == ds_name][0]
base = ds_name
if (ds_id,) in scd2:
vf, vt, ic = scd2[(ds_id,)]
source_ctes.append(
f"{ds_name.replace('.', '_')}_f AS (\n"
f" SELECT * FROM {base}\n"
f" WHERE TIMESTAMP '{AS_OF}' BETWEEN {vf} AND COALESCE({vt}, TIMESTAMP '9999-12-31 23:59:59')\n"
f")"
)
else:
source_ctes.append(f"{ds_name.replace('.', '_')}_f AS (SELECT * FROM {base})")
with_clause = ",\n ".join(source_ctes)
# Render joins
from_parts = []
if joins:
# start from the first 'needed' as driving table
base_name = sorted(needed)[0].replace('.', '_') + "_f"
sql_from = base_name
for l, r, jt, cond in joins:
if name_map[l] in needed and name_map[r] in needed:
left_alias = name_map[l].replace('.', '_') + "_f"
right_alias = name_map[r].replace('.', '_') + "_f"
sql_from += f"\n {jt} JOIN {right_alias} ON {cond.replace(name_map[l], left_alias).replace(name_map[r], right_alias)}"
from_sql = sql_from
else:
from_sql = ",\n ".join([n.replace('.', '_') + "_f" for n in sorted(needed)])
sql = f"""
CREATE OR REPLACE VIEW core.public.party AS
WITH
{with_clause}
SELECT
{select_list}
FROM
{from_sql};
"""
print(sql)
This keeps SCD2 logic declarative: contracts and joins live in metadata; the generator composes the final query.
6b) dbt Export (model.sql + schema.yml)
Add a small writer to emit the generated SQL as a dbt model and a minimal schema.yml.
from pathlib import Path
project_dir = Path('/mnt/data/dbt_demo')
(project_dir / 'models').mkdir(parents=True, exist_ok=True)
model_path = project_dir / 'models' / 'core__party.sql'
model_path.write_text(sql)
schema_yml = f"""
version: 2
models:
- name: core__party
description: Canonical Party view generated from metadata mappings.
columns:
{chr(10).join([f" - name: {c}\n tests: []" for c in expressions.keys()])}
"""
(project_dir / 'models' / 'schema.yml').write_text(schema_yml)
print('dbt assets written to', project_dir)
You can now copy these files into your dbt project (or run from this directory) to operationalize the model.
6c) Notion → DuckDB Sync (one-way)
Keep business edits in Notion and sync into meta.*. Below is a sketch; replace NOTION_SECRET and DATABASE_ID.
import os, requests, json, duckdb
NOTION_SECRET = os.environ.get('NOTION_SECRET')
DATABASE_ID = os.environ.get('NOTION_DB_MAPPINGS')
headers = {
'Authorization': f'Bearer {NOTION_SECRET}',
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json'
}
# pull pages
resp = requests.post(
f'https://api.notion.com/v1/databases/{DATABASE_ID}/query',
headers=headers,
json={"page_size": 100}
)
resp.raise_for_status()
rows = resp.json()['results']
# transform into meta.mapping rows
to_upsert = []
for p in rows:
props = p['properties']
target_ds = int(props['Target Dataset']['number'])
target_col = props['Target Column']['title'][0]['plain_text']
kind = props['Logic Kind']['select']['name']
payload = props['Logic Payload']['rich_text'][0]['plain_text']
prec = int(props.get('Precedence', {}).get('number') or 100)
note = props.get('Business Note', {}).get('rich_text', [{}])[0].get('plain_text','')
to_upsert.append((target_ds, target_col, kind, payload, prec, note))
con.execute("BEGIN")
for target_ds, target_col, kind, payload, prec, note in to_upsert:
con.execute(
"""
INSERT INTO meta.mapping (mapping_id, target_ds_id, target_col, logic_kind, logic_payload, precedence, note)
SELECT COALESCE(MAX(mapping_id),0)+1, ?, ?, ?, ?, ?, ? FROM meta.mapping
""", [target_ds, target_col, kind, payload, prec, note]
)
con.execute("COMMIT")
Tip: add idempotency (e.g., a
_notion_page_id_column +_MERGE_) and a small scheduler to make sync robust.
7) What This Demonstrates
- Business-friendly mapping lives in metadata, not code.
- The same metadata can generate executable SQL.
- Governance and lineage come for free because the rules are queryable.
Appendix: Suggested Notion Properties for Mappings
- Target Dataset (select)
- Target Column (text)
- Logic Kind (select: copy/const/concat/coalesce/lookup/sql)
- Logic Payload (JSON)
- Precedence (number)
- Business Note (rich text)
- Reviewed By / Approved By (people)