-
Notifications
You must be signed in to change notification settings - Fork 8
# Making test data for an LDM‐shaped database
What the LDM-to-Django mapping means in practice when you sit down to write test data — and why subtypes are the part that catches people out.
Background reading, not repeated here:
- How the BIRD model becomes a working database — the non-technical overview
-
From SQL Developer LDM to
bird_data_model.py— the full mapping, including arcs and delegates -
Unique IDs and
the…foreign keys — the equivalent page for the Input Layer
The LDM keeps BIRD's taxonomy, Django implements a taxonomy as multi-table inheritance, and multi-table inheritance means one business thing is stored as several rows in several tables — so test data has to be written that way too.
| Feature | What it means for a row of test data |
|---|---|
| Surrogate key on root classes only | Only the top of a hierarchy has a <CLASS>_uniqueID. Subtypes are keyed by their link to the parent. |
| Multi-table inheritance | Each class in the chain has its own table. A leaf instance needs a row in every table from the root down. |
| Arcs and delegates | A thing classified on more than one axis needs a _delegate foreign key pointed at a row in an arc-member table — itself a chain of rows. |
Scale, for a full BIRD LDM: roughly 646 classes, of which 199 are roots and 447 are subclasses, plus 46 arc classes. Most classes you would want to create data for are subtypes.
Take the example from the LDM mapping doc: a balance-sheet-recognised financial asset instrument, under IFRS.
class Financial_asset_instrument_type(models.Model):
test_id = models.CharField(...)
Financial_asset_instrument_type_uniqueID = models.CharField(..., primary_key=True)
...
class BLNC_SHT_RCGNSD_FNNCL_ASST_INSTRMNT(Financial_asset_instrument_type):
Balance_sheet_recognised_financial_asset_instrument_type_delegate = models.ForeignKey(
"Balance_sheet_recognised_financial_asset_instrument_type", models.SET_NULL, ...)
...
class BLNC_SHT_RCGNSD_FNNCL_ASST_INSTRMNT_IFRS(Balance_sheet_recognised_financial_asset_instrument_type):
...Storing one of these touches four tables:
flowchart TD
R["Financial_asset_instrument_type<br/>uniqueID = INST_1<br/>(root: carries the key and test_id)"]
C["BLNC_SHT_RCGNSD_FNNCL_ASST_INSTRMNT<br/>financial_asset_instrument_type_ptr_id = INST_1"]
A["Balance_sheet_recognised_financial_asset_instrument_type<br/>arc class, uniqueID = INST_1_IFRS"]
M["BLNC_SHT_RCGNSD_FNNCL_ASST_INSTRMNT_IFRS<br/>..._type_ptr_id = INST_1_IFRS"]
R -->|"inheritance"| C
C -->|"..._type_delegate"| A
A -->|"inheritance"| M
Two vertical links are inheritance (same thing, more specific) and one horizontal link is a delegate (a classification on a second axis). Both are foreign keys in the database, but they mean different things and you fill them in differently.
A subtype has no _uniqueID of its own. Django gives it a primary key that is
also the link to its parent:
financial_asset_instrument_type_ptr = models.OneToOneField(
"pybirdai.financial_asset_instrument_type",
on_delete=models.CASCADE, parent_link=True, primary_key=True, ...)The column is <parent class name, lowercased>_ptr_id, and its value must
equal the root's _uniqueID. In a fixture CSV that means the same identifier
string appears once per table in the chain:
| File | Key column | Value |
|---|---|---|
financial_asset_instrument_type.csv |
Financial_asset_instrument_type_uniqueID |
INST_1 |
blnc_sht_rcgnsd_fnncl_asst_instrmnt.csv |
financial_asset_instrument_type_ptr_id |
INST_1 |
That repetition is the point: it is what makes the four rows one thing.
Attributes are declared exactly once, on the class that owns them —
remove_duplicate_attributes_in_subclasses deletes the copies SQL Developer
repeats on every subtype. So each table stores its own columns and nothing else.
Fixture CSVs follow the tables, not the classes:
A fixture CSV holds a table's local columns only: its link to the parent, plus the fields declared on that class. Inherited values belong in the parent's own CSV. —
get_local_field_export_map()intest_data_template_utils.py
The Excel template follows the same rule. A worksheet for a subtype offers its link to the parent plus the fields declared on that class, and nothing else — so what you can type into a sheet is exactly what the fixture CSV for that table will hold.
Because a subtype's sheet does not offer its inherited fields, the parent
tables come with it: ask the template for a subtype and its ancestors are added
to the workbook automatically, listed on _Table_Index as parent table rather
than selected. Fill inherited values there.
_Table_Index
BIRD model/table Worksheet Included as
BLNC_SHT_RCGNSD_FNNCL_ASST_INSTRMNT blnc_sht_... selected
Financial_asset_instrument_type financial... parent table
Older workbooks. Templates generated before this change do show inherited columns on subtype sheets, and values entered there are dropped on conversion — reported back as
ignored_columns, in the JSON response when you import into a scenario or in_conversion_report.jsoninside the downloaded zip. If a value you entered vanishes, check that report, then re-export the template.
Inheritance gives you one axis. Every other axis is an arc, and choosing a value
on it means pointing the _delegate foreign key at a row in the arc-member
table.
Practical consequences:
-
The delegate target is a chain too. Before you can point at
..._IFRS, that arc-member row must exist — which means a row in the arc class table and a row in the member table, sharing a key, exactly as above. - The delegate stores the arc class's key, since that is what the foreign key points at. Under multi-table inheritance the member and the arc class share a key value, so the value you write is the arc member's identifier.
-
A null delegate is legal and silent. Delegates are emitted
models.SET_NULL, blank=True, null=True, so a thing left unclassified on an axis loads without complaint and then fails to match any filter written against that axis. -
_delegateis a naming contract. Forward engineering, join generation and the ETL filters find these fields by suffix. Don't rename the columns in a fixture header to something tidier.
With 646 classes and several orthogonal axes, "make me a test instrument" is not
a well-formed request until you have chosen a leaf on every axis. That
combinatorial question is precisely what the
SubtypeExploder
answers: it walks the hierarchy and every delegate and writes the valid
combinations to results/csv/<entity>_discrimitor_combinations_summary.csv (and
a _full variant that also lists the columns each combination needs).
Use it as the menu:
- A row of that CSV is one useful concrete thing — "a credit card debt acting in the role of an on-balance-sheet instrument" — and names every class in its chain.
- The
_fullvariant tells you which columns that combination actually needs, so you fill in a handful of fields rather than every field of every table. - Its own docstring is honest about the limitation: the LDM permits some combinations that are not real-world things (a loan in the role of an off-balance-sheet item). The CSV lists what is structurally valid; you still choose what is meaningful.
Two classes are not things to instantiate on their own:
- Arc classes exist so a delegate has something to point at. They are marked abstract in the blueprint and skipped by the exploder, even though they get a real table.
- A bare parent row — a row in a root table with no subtype row beneath it — is an instrument that is of no particular kind. Occasionally that is what you want; usually it is a mistake. See below.
Nothing here raises an error at load time. Fixtures are inserted with
connection.constraint_checks_disabled(), so the database will accept all of it
and the damage shows up as a report line that is empty or wrong.
Querying a subtype makes Django join to the parent table. If the parent row is missing, the join finds nothing and the row you inserted is simply not returned — it is in the table, invisible to every query, including the ones your transformation runs.
The loader sorts fixture files so parents load first (parent links are foreign
keys, so they are part of the dependency sort in
_sort_files_by_model_dependencies). Ordering is handled; existence is your
job.
Querying a parent class returns all subtype rows too — that is the payoff of multi-table inheritance. So if you create a root row for "instrument 1" and, separately, a leaf row with a different key for the same conceptual instrument, a filter over the root class sees two instruments. Amounts double. One thing means one key, used all the way down the chain.
Same as the input layer: the value must match an existing key exactly, nothing checks it, and the consequence is a join that quietly returns nothing.
Only possible with a workbook generated before subtype sheets were trimmed to
their own columns, or with one you assembled by hand. The values are dropped on
conversion and listed as ignored_columns — check that report, and re-export the
template.
| Link | on_delete |
Effect of deleting the target |
|---|---|---|
| Parent link (inheritance) | CASCADE |
Deleting the root row deletes the whole chain |
| Delegate | SET_NULL |
Arc-member row goes; the thing survives, unclassified on that axis |
| Ordinary relationship | SET_NULL |
Related row goes; the link is emptied |
Deleting a root is therefore the clean way to remove a thing; deleting from the middle leaves orphans that queries will not return but that still occupy the table.
The emitter adds test_id to classes with no superclass. Subtype tables do not
have the column, so you tag a scenario at the root and rely on the chain — and on
the CASCADE above — for the rest. Do not expect to filter a subtype table by
test_id directly.
BLNC_SHT_RCGNSD_FNNCL_ASST_INSTRMNT.objects.create(
Financial_asset_instrument_type_uniqueID="INST_1",
test_id="scenario_1",
...,
Balance_sheet_recognised_financial_asset_instrument_type_delegate=ifrs_row,
)One call, and Django inserts into every table in the chain with a consistent key.
You still have to create ifrs_row first, but the inheritance bookkeeping is
free. This is the right route for scripted setup and for anything you would
otherwise get wrong by hand.
You are writing table rows, so every point on this page applies: one file per table in the chain, the key repeated in each, local columns only, delegates pointing at rows that exist. In exchange you get files that can be reviewed, diffed, and re-loaded, which is why the shipped test suites are CSV.
Worth being explicit about, because it determines what you should be writing in the first place.
The transformations and datapoint tests run against the input layer. Forward
engineering folds an LDM subtype chain into an input layer table by reducing
discriminators: what the LDM stores as four rows across four tables, the input
layer stores as one row with a type column set to a code. The
entity_member annotation on each subtype class records exactly that mapping —
discriminator_field, member_code, member_label (see
specs/BIRD_LDM_ANNOTATIONS_SPEC.md).
flowchart LR
L["LDM<br/>four rows, four tables<br/>identity by inheritance"]
F["Forward engineering<br/>reduce discriminators"]
I["Input layer<br/>one row<br/>identity by a TYP code"]
L --> F --> I
So:
- Test data written at LDM level is for exercising the model itself and for driving ETL generation.
- Test data for a datapoint test belongs in the input layer, where a subtype
is a coded value rather than a chain — the shape described in
Unique IDs and
the…foreign keys. - The discriminator combination CSVs are the bridge: they enumerate LDM subtype
combinations and the input layer columns they correspond to, which is what
GenerateETLconsumes.
If you find yourself hand-writing a four-table chain in order to test a FINREP figure, you are probably working at the wrong level.
- Pick a combination from the discriminator combination CSV, not from imagination
- Identify the root class of the chain — it owns the
_uniqueID - Choose one identifier and use it as the key in every table of the chain
- One CSV per table; each carries its parent link plus its own columns only
- Inherited values go in the parent's file or sheet, never the child's
- For each additional axis: create the arc-member chain, then point the
_delegateat it - No bare parent row left over for a thing that also has a subtype row
-
test_idset on the root row - After loading, query the leaf class — if it returns nothing, a parent row is missing
| What | Where |
|---|---|
| LDM import (keys, arcs, delegates, subtypes) | birds_nest/pybirdai/process_steps/sqldeveloper_import/import_sqldev_ldm_to_blueprint.py |
Django emitter (test_id, inheritance order) |
birds_nest/pybirdai/process_steps/sqldeveloper_import/emit_django_from_blueprint.py |
| Parent-link generation in migrations | birds_nest/pybirdai/process_steps/database_setup/migration_generator.py |
| Subtype explosion / valid combinations | birds_nest/pybirdai/process_steps/generate_test_data/traverser.py |
| Local-columns-only fixture rule | birds_nest/pybirdai/utils/datapoint_test_run/test_data_template_utils.py |
| Workbook → CSV conversion, ignored columns | birds_nest/pybirdai/utils/datapoint_test_run/excel_to_csv_converter.py |
| Fixture loading and dependency ordering | birds_nest/pybirdai/utils/datapoint_test_run/csv_fixture_loader.py |
| Subtype ↔ discriminator contract | specs/BIRD_LDM_ANNOTATIONS_SPEC.md |