etl-to-dbt — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited etl-to-dbt (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
You are converting ETL pipeline metadata (Informatica IICS, PowerCenter XML, or similar) into clean, production-grade dbt models. The goal is not a mechanical 1:1 translation but generating SQL that a data engineer would be proud to maintain.
1. Analyze → Read the mapping/workflow file, identify sources, targets, transformations
2. Plan → Sketch the dbt model graph (staging → intermediate → final)
3. Generate → Write SQL models + YAML metadata
4. Validate → Check column coverage, data type fidelity, test coverageWhen given an IICS/Informatica mapping (XML or JSON), extract:
Sources — from Source Qualifier or Read transformations:
Target — from Target Definition or Write transformations:
Transformations in order — see the transformation reference below.
Typical mapping → model graph:
Raw source table(s)
↓
stg_<source>__<table>.sql (one per source table, light casting)
↓
int_<entity>__<verb>.sql (joins, lookups, aggregations)
↓
fct_<fact>.sql / dim_<dim>.sql (final mart model)Simple mappings (single source, expression only) collapse to two layers. Complex mappings (multiple sources, lookups, aggregations) use all three.
The entry point — generates the staging SELECT.
-- stg_crm__customers.sql
select
cast(CUST_ID as varchar(36)) as customer_id,
cast(CUST_NAME as varchar(255)) as customer_name,
cast(CREATED_DT as timestamp_ntz) as created_at
from {{ source('crm', 'customers') }}
-- If SQ has a filter:
where IS_ACTIVE = 'Y'Computed columns → computed expressions in SELECT or a CTE.
-- Informatica: IIF(STATUS = 'A', 'Active', 'Inactive')
case when status = 'A' then 'Active' else 'Inactive' end as status_label,
-- Informatica: TO_DATE(DATE_STR, 'YYYY-MM-DD')
try_to_date(date_str, 'YYYY-MM-DD') as parsed_date,
-- Informatica: ISNULL(AMOUNT) → NVL(AMOUNT, 0)
coalesce(amount, 0) as amount,
-- Informatica: SUBSTR(NAME, 1, 50)
left(name, 50) as short_name,
-- Informatica: SYSTIMESTAMP
current_timestamp() as processed_atBecomes a WHERE clause in the current CTE.
where status = 'ACTIVE'
and amount > 0Becomes a SQL JOIN. Check the join condition and type (normal = inner, master-outer = left join).
-- Master: orders, Detail: customers → left join orders to customers
select
o.order_id,
o.amount,
c.customer_name
from {{ ref('stg_oms__orders') }} o
left join {{ ref('stg_crm__customers') }} c
on o.customer_id = c.customer_idBecomes a LEFT JOIN or a scalar subquery. Lookup condition = ON clause.
-- Lookup: find product_name from products table by product_id
left join {{ ref('stg_catalog__products') }} p
on f.product_id = p.product_id
-- Then reference p.product_name in the SELECTWhen the lookup returns a single value and the source is small, a subquery works:
(select product_name from {{ ref('dim_products') }} where product_id = f.product_id limit 1)
as product_nameBecomes GROUP BY.
select
account_id,
date_trunc('month', order_date) as order_month,
sum(amount) as total_amount,
count(*) as order_count
from {{ ref('int_orders__enriched') }}
group by 1, 2Splits a stream by condition — becomes either:
-- Router with 3 groups landing in one target:
with routed as (
select *,
case
when region = 'EMEA' then 'europe'
when region = 'AMER' then 'americas'
else 'apac'
end as region_group
from source
)Informatica sorts for dedup or ordered processing. In SQL, use window functions:
-- Instead of ORDER BY (which is meaningless without LIMIT), use ROW_NUMBER for dedup:
qualify row_number() over (partition by account_id order by updated_at desc) = 1→ UNION ALL (Informatica Union always includes all rows)
select account_id, amount, 'source_a' as data_source from {{ ref('stg_a__accounts') }}
union all
select account_id, amount, 'source_b' as data_source from {{ ref('stg_b__accounts') }}Pivots columns into rows — use UNPIVOT or LATERAL FLATTEN (Snowflake):
select account_id, key as metric_name, value as metric_value
from source
unpivot (value for key in (metric_q1, metric_q2, metric_q3, metric_q4))row_number() over (partition by account_id order by score desc) as rnk
-- or:
rank() over (...)
dense_rank() over (...)Use ROW_NUMBER() for surrogate keys, or {{ dbt_utils.generate_surrogate_key([...]) }} for deterministic hashes.
row_number() over (order by created_at) as sequence_numMaps to an incremental model with merge strategy:
{{ config(
materialized='incremental',
unique_key='account_id',
incremental_strategy='merge'
) }}
select ...
{% if is_incremental() %}
where updated_at > (select max(updated_at) from {{ this }})
{% endif %}-- Hash PII
sha2(email, 256) as email_hash,
-- Truncate precision
date_trunc('month', birth_date) as birth_month,
-- Redact
'***REDACTED***' as ssn| Informatica Type | Snowflake Type |
|---|---|
| String(n) | VARCHAR(n) |
| Nstring(n) | VARCHAR(n) |
| Integer | NUMBER(10,0) |
| Small Integer | NUMBER(5,0) |
| Big Integer | NUMBER(19,0) |
| Decimal(p,s) | NUMBER(p,s) |
| Float | FLOAT |
| Double | DOUBLE |
| Date/Time | TIMESTAMP_NTZ |
| Date | DATE |
| Time | TIME |
| Binary | BINARY |
| Text / Memo | VARCHAR(16777216) |
Always cast at the staging layer — never propagate raw string types into marts.
From Source Qualifier metadata:
version: 2
sources:
- name: crm # logical source name (short, snake_case)
database: RAW_DB # Snowflake database
schema: SALESFORCE_CRM # Snowflake schema
freshness:
warn_after: {count: 6, period: hour}
error_after: {count: 24, period: hour}
loaded_at_field: _loaded_at
tables:
- name: account # dbt source table name
identifier: ACCOUNT_TABLE # actual Snowflake table name (if different)
description: "Raw CRM accounts from Salesforce."version: 2
models:
- name: stg_crm__accounts
description: "Staged CRM accounts — cast and renamed."
columns:
- name: account_id
data_tests: [not_null, unique]
- name: account_name
data_tests: [not_null]
- name: created_at
data_tests: [not_null]
- name: status
data_tests:
- accepted_values:
values: ["Active", "Inactive", "Pending"]| Layer | Pattern | Example |
|---|---|---|
| Staging | stg_<source>__<table> | stg_crm__accounts |
| Intermediate | int_<entity>__<verb> | int_orders__enriched |
| Fact | fct_<event_plural> | fct_order_lines |
| Dimension | dim_<entity> | dim_customers |
| Snapshot | snp_<entity> | snp_accounts |
Source names: the ETL connection/schema name, lowercased. Table names: the source table name, lowercased, no prefixes.
-- models/staging/stg_crm__accounts.sql
with source as (
select * from {{ source('crm', 'account') }}
),
renamed as (
select
-- ids
cast(id as varchar(36)) as account_id,
cast(parent_id as varchar(36)) as parent_account_id,
-- attributes
cast(name as varchar(255)) as account_name,
lower(cast(type as varchar(50))) as account_type,
cast(industry as varchar(100)) as industry,
-- booleans
is_deleted::boolean as is_deleted,
-- dates
cast(created_date as timestamp_ntz) as created_at,
cast(last_modified_date as timestamp_ntz) as updated_at,
-- metadata
_loaded_at as _loaded_at
from source
where is_deleted = false -- apply SQ filter here
)
select * from renamedBefore declaring a migration done:
source() reference and freshness confignot_null + unique testsrelationships testsunique_key and a filter on is_incremental()ref() and source()~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.