Chapter Nine: Limits and patterns
What TypeDB cannot express, and how to model around it
| If you are starting here, run the full schema and the data from Chapter Six in an empty database. |
The mapping in Chapter One marked some constructs as missing. This chapter takes them in turn, with the pattern to use instead of each.
One type per instance
Every instance has exactly one most-specific type, fixed when it is created. Two consequences:
-
No overlapping or multiple classification. Nothing can be both a mill and a refinery, or a member of two unrelated types.
-
No dynamic classification. An instance cannot become another type later. There are no phases in UFO’s sense, such as an active and a closed mill, implemented as types.
Pattern: separate instances, related
The data contained one case that looks like multiple classification: LDC EAST INDONESIA is a refinery and an exporting company.
Chapter Four modeled it as two instances, a refinery and a company, linked by ownership.
In general, when one name covers two kinds of thing, check whether it refers to two things with a relationship between them.
If so, model both, and the relationship.
Pattern: unary relations as classifications
When an instance needs a second classification, a relation with a single role can stand in for it. This is sometimes called a stored cast: the relation gives the instance an extra classification, with its own attributes and roles, without changing its type. The schema modeling guide calls these relations components.
Here, a kabupaten and a mill, which share no supertype, can both be classified as high-risk:
#!test[schema]
#{{
define
attribute name, value string;
### Places ###
attribute country_name, sub name;
attribute province_name, sub name;
attribute kabupaten_name, sub name;
entity place @abstract;
entity country,
sub place,
owns country_name @key;
entity province,
sub place,
owns province_name @key;
entity kabupaten,
sub place,
owns kabupaten_name @key;
### Places hierarchy ###
relation containment,
relates parent @card(1),
relates child @card(1);
entity country,
plays containment:parent;
entity province,
plays containment:child @card(0..1), # at most one parent
plays containment:parent;
entity kabupaten,
plays containment:child @card(0..1); # at most one parent
### Companies and facilities ###
attribute facility_name, sub name;
attribute mill_name, sub facility_name;
attribute refinery_name, sub facility_name;
attribute company_name, sub name;
entity legal_entity @abstract;
entity facility @abstract,
sub legal_entity,
plays ownership:owned @card(0..1); # at most one owner
entity mill,
sub facility,
owns mill_name;
entity refinery,
sub facility,
owns refinery_name;
entity company,
sub legal_entity,
owns company_name @key,
plays ownership:owner;
relation ownership,
relates owner @card(1),
relates owned @card(1);
# companies can also be owned (by a parent company)
entity company,
plays ownership:owned @card(0..1);
### Commodities ###
attribute commodity_name,
sub name,
value string @values("PALM OIL", "REFINED PALM OIL");
entity commodity,
owns commodity_name @key;
### Trade flow ###
attribute volume_tonnes, value double;
attribute deforestation_ha, value double;
attribute usd, value decimal;
relation trade_flow,
relates origin @card(1),
relates mill @card(1),
relates refiner @card(0..1), # optional
relates exporter @card(1),
relates importer @card(1),
relates destination @card(1),
relates commodity @card(1),
owns volume_tonnes @card(1),
owns deforestation_ha @card(1),
owns usd @card(1);
entity kabupaten, plays trade_flow:origin;
entity mill, plays trade_flow:mill;
entity refinery, plays trade_flow:refiner;
entity company,
plays trade_flow:importer,
plays trade_flow:exporter;
entity country,
plays trade_flow:destination;
entity commodity,
plays trade_flow:commodity;
#}}
#!test[schema]
define
attribute risk_reason, value string;
relation high_risk,
relates subject @card(1),
owns risk_reason @card(1);
entity kabupaten, plays high_risk:subject @card(0..1);
entity mill, plays high_risk:subject @card(0..1);
#!test[write]
insert
$k isa kabupaten, has kabupaten_name "KUTAI BARAT";
$m isa mill, has mill_name "EXAMPLE RISK MILL";
high_risk (subject: $k), has risk_reason "Deforestation hotspot";
high_risk (subject: $m), has risk_reason "Sources from a deforestation hotspot";
A query for the classification finds instances of both types (2 answers):
#!test[read, count=2]
match
$h isa high_risk, links (subject: $x), has risk_reason $reason;
$x isa! $t;
Unlike a subtype, the classification can overlap with any other, and it can change: deleting the high_risk relation declassifies its subject and leaves the subject itself untouched.
Pattern: roles as relations
Anti-rigid types whose membership depends on what an instance does, such as exporter, are roles in relations. Chapter Seven showed how to derive the set of exporters with a function when a query needs it as a type.
Pattern: phases as attributes
A phase depends on an instance’s own state, such as a mill being active or closed. Model it as an attribute:
#!test[schema]
define
attribute operating_status, value string @values("ACTIVE", "CLOSED");
entity mill, owns operating_status;
Active mills are then a pattern on the attribute, has operating_status "ACTIVE", not a type.
Pattern: relations with validity periods
When the history matters, record a time-dependent fact as a relation that owns its dates. For example, a mill being certified under a sustainability scheme is a role the mill plays in a certification, valid for a period:
#!test[schema]
define
attribute standard_name, sub name;
attribute valid_from, value date;
attribute valid_until, value date;
entity certification_scheme,
owns standard_name @key,
plays certification:scheme;
relation certification,
relates certified @card(1),
relates scheme @card(1),
owns valid_from @card(1),
owns valid_until @card(0..1);
entity mill, plays certification:certified;
A mill is currently certified if it plays certified in a certification with no end date, or one that ends in the future.
That condition is a query or a function.
TypeDB has no built-in temporal model, so use validity periods like these to record time-dependent facts, including the history of a phase.
General integrity constraints
TypeDB’s schema constraints each involve a single type, role or ownership: a cardinality, a key, a value set. Constraints that relate several facts to each other cannot be declared:
-
comparisons between values, such as
TAX < SALARY, -
conditional constraints, such as "a flow of refined palm oil has a refiner",
-
uniqueness over a combination of roles, such as "no two ownerships with the same owner and owned",
-
constraints along a path, such as "a flow’s destination is not the country it was produced in".
Pattern: invariants as queries
Each of these constraints can be written as a query for its violations, which should return zero. Run the queries after loading data, or before committing a transaction that could break them.
Refined palm oil without a refiner:
#!test[read, count=1]
match
$tf isa trade_flow, links (commodity: $c);
$c has commodity_name "REFINED PALM OIL";
not { $tf links (refiner: $r); };
reduce $violations = count;
Two ownerships with the same owner and owned:
#!test[read, count=1]
match
$o1 isa ownership, links (owner: $a, owned: $b);
$o2 isa ownership, links (owner: $a, owned: $b);
not { $o1 is $o2; };
reduce $violations = count;
A flow whose destination is its country of production:
#!test[read, count=1]
match
$tf isa trade_flow, links (origin: $k, destination: $d);
containment (child: $k, parent: $p);
containment (child: $p, parent: $d);
reduce $violations = count;
Each returns $violations of 0 on this data.
Stored as functions with define fun, invariants like these become part of the model, and any application can call them.
Pattern: turn a conditional constraint into a structural one
Some conditional constraints disappear when the condition becomes a type. "A refined flow has exactly one refiner, and a crude flow has none" is a constraint on two kinds of flow, so model the two kinds, and give the refiner role only to refined flows.
Create a second database and run the full schema from Chapter Six in it, without the data.
Then remove the refiner role from trade_flow:
#!test[schema]
undefine
plays trade_flow:refiner from refinery;
relates refiner from trade_flow;
Then make trade_flow abstract, with two subtypes:
#!test[schema]
define
relation trade_flow @abstract;
relation crude_flow sub trade_flow;
relation refined_flow sub trade_flow,
relates refiner @card(1);
entity refinery, plays refined_flow:refiner;
A crude flow with a refiner is now rejected before it runs, because crude_flow has no refiner role:
#!test[write, fail_at=runtime]
insert
$k isa kabupaten, has kabupaten_name "BERAU";
$m isa mill, has mill_name "EXAMPLE MILL";
$r isa refinery, has refinery_name "EXAMPLE REFINERY";
$e isa company, has company_name "EXAMPLE EXPORTER";
$i isa company, has company_name "EXAMPLE IMPORTER";
$d isa country, has country_name "SPAIN";
$co isa commodity, has commodity_name "PALM OIL";
crude_flow (origin: $k, mill: $m, refiner: $r, exporter: $e, importer: $i, destination: $d, commodity: $co),
has volume_tonnes 1.0, has deforestation_ha 0.0, has usd 100.0dec;
A refined flow without a refiner is rejected at commit, because the role is required:
#!test[write, fail_at=commit]
insert
$k isa kabupaten, has kabupaten_name "BERAU";
$m isa mill, has mill_name "EXAMPLE MILL";
$e isa company, has company_name "EXAMPLE EXPORTER";
$i isa company, has company_name "EXAMPLE IMPORTER";
$d isa country, has country_name "SPAIN";
$co isa commodity, has commodity_name "REFINED PALM OIL";
refined_flow (origin: $k, mill: $m, exporter: $e, importer: $i, destination: $d, commodity: $co),
has volume_tonnes 1.0, has deforestation_ha 0.0, has usd 100.0dec;
These changes only work in a database without existing trade_flow instances, since an abstract type cannot have instances of its own.
The commodity is now partly redundant with the relation’s type, and a flow of refined palm oil could still be recorded as a crude_flow, so there is a trade-off.
Choose the model based on which constraint matters more.
Closed world
A TypeDB database contains the facts it has been told, and a query’s answers are drawn from those facts alone.
not { … } means "not recorded", which is the same as "false" only if the data is complete.
In this dataset, a flow with no refiner was recorded as crude, so the two coincide.
For data you know to be incomplete, model unknown values explicitly, for example with an attribute that records the absence, rather than relying on negation.
Next: Epilogue.