Two-File Keyed JOIN (concat + pre_pass + LOOKUP)¶
What
Enrich a fact table that carries only a foreign key (orders →
customer_id) with the human details from a separate dimension table
(customers → name, city) — a real relational JOIN across two sources.
Synthetic / teaching example
The data here is constructed, not sourced.
joined_input.csv is the two tables already stacked into one file with a _type
marker (customer rows + order rows). The problem class — facts keyed to a
separate lookup table — is universal; the rows are not.
Why interesting¶
bxp reads one file per pass, so a cross-file join needs the two sources
brought together first. Once they share a file with a row-type marker, a keyed
join is just a pre_pass over the dimension rows plus a LOOKUP on the fact
rows — no database, no JOIN SQL. This generalises the single-file
real-world/gtfs-stops-selfjoin (a self-join) to two distinct sources.
flowchart TD
IN["joined_input.csv<br/><small>customer + order rows, _type marker</small>"] --> PP["pre_pass<br/><small>_type = 'customer'</small>"]
PP --> IDX[("index<br/><small>name / city by customer_id</small>")]
IN --> ROWS["order rows<br/><small>_type = 'order'</small>"]
IDX -.->|LOOKUP customer_id| ROWS
ROWS --> OUT["enriched orders<br/><small>+ customer_name / customer_city</small>"]
Problem class documented in. (sources for the problem class — not for the data)
- The star-schema fact/dimension split is the foundational pattern of every relational and analytics database (Kimball dimensional modelling); resolving a foreign key to its row is the single most common data-prep step.
The trick¶
(see inline comments in sample.json)
- Stack the two files with a
_typemarker. Here it ships ready-made; to produce it from two separate files inside bxp, see ../multi-stage-etl, which builds the same shape withcombined_output. pre_passover_type = 'customer'— indexname/citybycustomer_id(the dimension side).LOOKUP([customer_id], …)on the order rows — resolve the foreign key.row_rulesemits only_type = 'order'; the dimension rows were just the lookup source.
Final result¶
Each order gains its customer's name and city. An order whose key is not in
the dimension (C-9) keeps empty details — unmatched rows stay visible, not
silently dropped:
order_id,customer_id,customer_name,customer_city,amount
1001,C-1,Acme s.r.o.,Praha,1250.5
1002,C-2,Globex a.s.,Brno,980
1003,C-1,Acme s.r.o.,Praha,540
1004,C-9,,,75
Sample data¶
Run it with bxp-cli --config ./sample.json --template two_file_join — the
stacked input and the full commented template:
{
// Teaching example — synthetic data. The classic relational JOIN: a fact table
// (orders) carries only a foreign key (customer_id); the human details
// (name, city) live in a separate dimension table (customers). bxp processes
// ONE file per pass, so the two sources are first stacked into one file with a
// `_type` marker column — `customer` rows and `order` rows together. (Here the
// combined file is shipped ready-made; for how to PRODUCE it from two separate
// files inside bxp, see ../multi-stage-etl, which builds the same shape with
// `combined_output`.)
//
// With both record types in one file, a pre_pass indexes the dimension rows
// and the main pass enriches each fact row via LOOKUP — a real keyed join.
conversion_templates: {
two_file_join: {
data_dir: ".",
file_pattern_in: ".csv",
file_pattern_out: ".csvx",
// PRE_PASS — scan the `customer` rows only and index name+city by
// customer_id. This is the dimension side of the join.
pre_pass: {
when: "[_type] = 'customer'",
key: "[customer_id]",
values: {
cust_name: "[name]",
cust_city: "[city]"
}
},
input_schema: {
$order_id: "[order_id]",
$customer_id: "[customer_id]",
$amount: "[amount]",
// THE JOIN — resolve the foreign key to the dimension's values. An order
// whose customer_id is not in the dimension (C-9) gets "" — LOOKUP
// returns empty for an unknown key, so unmatched rows are visible, not
// dropped.
$name: "LOOKUP([customer_id], 'cust_name')",
$city: "LOOKUP([customer_id], 'cust_city')"
},
// Emit only the fact (order) rows; the dimension rows were just the lookup
// source.
row_rules: [ { when: "[_type] = 'order'", rows: [ {} ] } ],
output_schema: {
order_id: "$order_id",
customer_id: "$customer_id",
customer_name: "$name",
customer_city: "$city",
amount: "$amount"
}
}
}
}