Extracting One-to-Many Tables
extract-one-to-many-tables.RmdOverview
A one-to-many (1:M) table has many rows per filing: a grant list, a roster of officers and their compensation, a schedule of foreign activities. The same set of fields repeats once per entry. Reassembling those entries correctly is the whole job — and it hinges on the TABLE_ID column produced during flattening.
Our example filing’s Schedule I lists 83 grants, and its Part VII lists 15 officers and directors — two clean one-to-many tables.
The repeating group
We’ll extract Schedule I, Part II — grants to U.S. organizations, SI-P02-T01-GRANTS-US-ORGS-GOVTS:
grants_long <- flat |>
filter(TYPE == "terminal", RDB_TABLE == "SI-P02-T01-GRANTS-US-ORGS-GOVTS")
n_distinct(grants_long$TABLE_ID) # number of grants in this filing
#> [1] 83
grants_long |>
select(TABLE_ID, VARIABLE_NAME, VALUE) |>
head(10)
#> TABLE_ID VARIABLE_NAME VALUE
#> 1 TID-00001 SI_02_GRANT_US_ORG_NAME_L1 100WOMEN STRONG
#> 2 TID-00001 SI_02_GRANT_US_ORG_ADDR_L1 714 EAST MARKET STREET
#> 3 TID-00001 SI_02_GRANT_US_ORG_ADDR_CITY LEESBURG
#> 4 TID-00001 SI_02_GRANT_US_ORG_ADDR_STATE VA
#> 5 TID-00001 SI_02_GRANT_US_ORG_ADDR_ZIP 20178
#> 6 TID-00001 SI_02_GRANT_US_ORG_EIN 541950727
#> 7 TID-00001 SI_02_GRANT_US_ORG_IRC_SECTION INTERFUND GRANT
#> 8 TID-00001 SI_02_GRANT_US_ORG_AMT_CASH 40250
#> 9 TID-00001 SI_02_GRANT_US_ORG_AMT_NONCSH 0
#> 10 TID-00001 SI_02_GRANT_US_ORG_PURPOSE HUMAN SERVICESLook at the TABLE_ID column: the first grant’s fields are all TID-00001, the second grant’s are all TID-00002, and so on. That is the entire trick of 1:M extraction — the positional index [N] from the XPath, captured as TABLE_ID, tells us which fields belong to the same entry.
Pivot — keyed by filing and TABLE_ID
The 1:1 pivot keyed only on OBJECTID. For 1:M we key on OBJECTID and TABLE_ID, so each entry becomes its own row:
grants_wide <- grants_long |>
select(OBJECTID, TABLE_ID, VARIABLE_NAME, VALUE) |>
pivot_wider(names_from = VARIABLE_NAME, values_from = VALUE, values_fill = "")
dim(grants_wide) # one row per grant
#> [1] 83 12
grants_wide |>
select(TABLE_ID, SI_02_GRANT_US_ORG_EIN, SI_02_GRANT_US_ORG_AMT_CASH) |>
head(5)
#> # A tibble: 5 × 3
#> TABLE_ID SI_02_GRANT_US_ORG_EIN SI_02_GRANT_US_ORG_AMT_CASH
#> <chr> <chr> <chr>
#> 1 TID-00001 541950727 40250
#> 2 TID-00002 811191778 37729
#> 3 TID-00003 453081114 77417
#> 4 TID-00004 271118675 63008
#> 5 TID-00005 541515133 350000This mirrors build_rdb_table(), which does the same pivot against the DuckDB FLATXML table and carries TABLE_ID through so the rows stay separated.
Why TABLE_ID is essential: drop it from the key and every grant’s values pile into a single cell. pivot_wider() can’t store thousands of EINs in one position, so it produces list-columns instead of a clean table:
grants_long |>
select(OBJECTID, VARIABLE_NAME, VALUE) |>
pivot_wider(names_from = VARIABLE_NAME, values_from = VALUE) |>
summarise(across(everything(), ~ class(.x)[1]))
#> Warning: Values from `VALUE` are not uniquely identified; output will contain list-cols.
#> • Use `values_fn = list` to suppress this warning.
#> • Use `values_fn = {summary_fun}` to summarise duplicates.
#> • Use the following dplyr code to identify duplicates.
#> {data} |>
#> dplyr::summarise(n = dplyr::n(), .by = c(OBJECTID, VARIABLE_NAME)) |>
#> dplyr::filter(n > 1L)
#> # A tibble: 1 × 11
#> OBJECTID SI_02_GRANT_US_ORG_NAME_L1 SI_02_GRANT_US_ORG_ADDR_L1 SI_02_GRANT_US_ORG_ADDR_CITY
#> <chr> <chr> <chr> <chr>
#> 1 character list list list
#> # ℹ 7 more variables: SI_02_GRANT_US_ORG_ADDR_STATE <chr>, SI_02_GRANT_US_ORG_ADDR_ZIP <chr>,
#> # SI_02_GRANT_US_ORG_EIN <chr>, SI_02_GRANT_US_ORG_IRC_SECTION <chr>,
#> # SI_02_GRANT_US_ORG_AMT_CASH <chr>, SI_02_GRANT_US_ORG_AMT_NONCSH <chr>,
#> # SI_02_GRANT_US_ORG_PURPOSE <chr>Each column comes back as a list — the tell-tale sign that a repeating group was pivoted without its instance key.
Header variants across schema versions
The full pipeline doesn’t match a single XPath. Because the IRS renamed nodes over the years, get_table_headers() stores several candidate header paths per table, and build_rdb_table() matches any of them:
get_table_headers()[["SI-P02-T01-GRANTS-US-ORGS-GOVTS"]]
#> [1] "//Form990ScheduleIPartII/RecipientTable" "//IRS990ScheduleI/RecipientTable"Whichever variant a given filing used, its rows land in the same logical table.
Edge case: ragged entries
Entries in a 1:M group are not required to carry the same fields — one grant may list a street address, another only a city, a third may add a non-cash valuation. Pivoting yields a ragged table, and values_fill = "" backfills the gaps so every row has the same columns:
# Fill rate of every grant field across all entries: 1 = always present.
grants_wide |>
summarise(across(-c(OBJECTID, TABLE_ID), ~ round(mean(.x != ""), 2))) |>
glimpse()
#> Rows: 1
#> Columns: 10
#> $ SI_02_GRANT_US_ORG_NAME_L1 <dbl> 1
#> $ SI_02_GRANT_US_ORG_ADDR_L1 <dbl> 1
#> $ SI_02_GRANT_US_ORG_ADDR_CITY <dbl> 1
#> $ SI_02_GRANT_US_ORG_ADDR_STATE <dbl> 1
#> $ SI_02_GRANT_US_ORG_ADDR_ZIP <dbl> 1
#> $ SI_02_GRANT_US_ORG_EIN <dbl> 0.99
#> $ SI_02_GRANT_US_ORG_IRC_SECTION <dbl> 1
#> $ SI_02_GRANT_US_ORG_AMT_CASH <dbl> 1
#> $ SI_02_GRANT_US_ORG_AMT_NONCSH <dbl> 1
#> $ SI_02_GRANT_US_ORG_PURPOSE <dbl> 1What you learned
- A 1:M table is the
TYPE == "terminal"rows for oneRDB_TABLEspread across manyTABLE_IDs (TID-00001,TID-00002, …). - Pivoting keyed on
OBJECTIDandTABLE_IDreassembles one row per entry — the core ofbuild_rdb_table(). - Dropping
TABLE_IDcollapses the group into unusable list-columns. -
get_table_headers()supplies multiple header variants so schema changes across years still resolve to one logical table. -
values_fill = ""squares off ragged entries that don’t all share the same fields.