Mapping rules
One conceptual schema, two database designs — and why each choice was made.
Why the mappings differ
An ORM schema is deliberately implementation-free: it says which facts exist and which combinations are legal, not where they are stored. Turning it into a database is a separate decision, and the right answer depends on the target. Factum performs both mappings from the same model and annotates each with the reasoning, so the output is reviewable rather than magic.
Throughout, this example model is used:
Person(.nr) works for Company(.name) -- each Person works for at most one Company, mandatory
Person(.nr) has Skill(.code) -- many to many
Person(.nr) is of GenderCode -- GenderCode is a value type, values {'M', 'F'}
Manager is a kind of Person
Relational (Rmap)
The relational mapping follows the classic Rmap procedure:
- a fact type whose only uniqueness constraint spans every role becomes its own table with a composite primary key;
- an n:1 or 1:1 fact type is absorbed as a column into the table of the object type playing the uniquely-constrained role — for 1:1, the mandatory side, so the column is never null;
- a unary fact type becomes a boolean column;
- mandatory roles produce
NOT NULL, optional rolesNULL; - an entity type's reference mode expands into its identifying column; compound identifiers expand recursively, and a type with no reference scheme gets a surrogate key and a mapping note saying so;
- objectified fact types map to their own table, keyed by the objectified roles;
- subtypes are absorbed into their supertype's table with optional columns, and
an exclusive subtype partition adds a discriminator column with a
CHECK; - value constraints become
CHECKconstraints.
Applied to the example:
CREATE TABLE "Person" (
"personNr" integer NOT NULL,
"companyName" varchar(255) NOT NULL, -- absorbed: Person works for Company
"genderCode" varchar(1) NOT NULL,
CONSTRAINT "PK_Person" PRIMARY KEY ("personNr"),
CONSTRAINT "CK_Person_genderCode" CHECK ("genderCode" IN ('M', 'F'))
);
CREATE TABLE "PersonHasSkill" ( -- m:n needs its own table
"personNr" integer NOT NULL,
"skillCode" varchar(255) NOT NULL,
CONSTRAINT "PK_PersonHasSkill" PRIMARY KEY ("personNr", "skillCode")
);
Dialects: PostgreSQL, SQL Server, MySQL, SQLite and ANSI SQL, chosen with
orm.ddl.dialect. Identifiers are quoted where the dialect would otherwise fold their
case.
Property graph (LadybugDB)
The graph mapping targets LadybugDB, whose Cypher DDL declares typed node and relationship tables. The rules:
- an entity type becomes a node table, keyed by its reference mode, by a
single-role preferred identifier over a value type, or — failing both — by a generated
SERIALkey, since LadybugDB requires a primary key; - a value type stays a property of the object it describes. It is promoted to a node only when it is played many-to-many or in an n-ary fact type, where a single-valued property could not hold it;
- a binary fact type between two nodes becomes a relationship table, with the multiplicity read off its uniqueness constraints;
- a unary fact type becomes a
BOOLEANproperty; - an n-ary or objectified fact type is reified into a node with one
MANY_ONErelationship per role.
// Entity type Person
CREATE NODE TABLE Person(
nr INT64 PRIMARY KEY, // Reference mode Person(.nr)
genderCode STRING // From "Person is of GenderCode"; mandatory; values {'M', 'F'}
);
CREATE REL TABLE WORKS_FOR(FROM Person TO Company, MANY_ONE);
CREATE REL TABLE HAS(FROM Person TO Skill, MANY_MANY);
CREATE REL TABLE IS_A_PERSON(FROM Manager TO Person, ONE_ONE);
Note what did not become a node: GenderCode is a lexical value played
single-valued, so it is a property. That is the difference between a model drawn by hand and one
derived from ORM — the decision follows from the constraints instead of taste.
Uniqueness becomes multiplicity
A uniqueness constraint on a role says each player of that role appears at most once, which is exactly what a "one" end of a relationship means:
| Uniqueness in ORM | Multiplicity | Reads as |
|---|---|---|
| On the from role only | MANY_ONE | Each Person works for at most one Company |
| On the to role only | ONE_MANY | Each Company employs at most one Person |
| On both roles | ONE_ONE | A one-to-one correspondence |
| Spanning both roles | MANY_MANY | Neither side is restricted |
The direction comes from the fact type's primary reading, so the relationship type reads the way you wrote the predicate.
N-ary fact types and reification
A property-graph edge joins exactly two nodes. A ternary or higher fact type is a hyperedge and
simply cannot be one, so Factum reifies it: the fact becomes a node, and each
role becomes a MANY_ONE relationship from that node to its player. This is the Levi
(bipartite) form of the hyperedge.
// "Student in Course during Semester scored Grade" — 4-ary
CREATE NODE TABLE StudentInCourseDuringSemesterScoredGrade(id SERIAL PRIMARY KEY);
CREATE REL TABLE HAS_STUDENT(FROM Enrolment TO Student,
FROM StudentInCourseDuringSemesterScoredGrade TO Student, MANY_ONE);
CREATE REL TABLE HAS_COURSE(FROM Enrolment TO Course,
FROM StudentInCourseDuringSemesterScoredGrade TO Course, MANY_ONE);
Two things worth noticing:
- Role links to the same player share one relationship table with several
FROM … TO …pairs, which LadybugDB supports, rather than producing a separate long-named table per fact type. - The generated node name is the whole reading, which is unambiguous but verbose.
Objectify the fact type in the diagram — give it a name like
Result— and the node takes that name instead. Objectification is ORM's own way of saying "this fact is a thing", and the mapping honours it.
Subtypes
Set orm.graph.subtypeStrategy:
nodeTable(default) — each subtype gets its own label and inherits the supertype's identifier, joined by anIS_Arelationship. Relationships can then point at the subtype specifically.absorb— subtypes are folded into the supertype's node with optional properties, and an exclusive partition adds a discriminator property. A role that is mandatory for the subtype becomes optional on the merged node, because it only binds for those instances; Factum notes this rather than silently over-constraining.
What a schema cannot enforce
LadybugDB checks primary keys and relationship multiplicities. It has no CHECK
constraints and no NOT NULL, so mandatory roles, value ranges, ring constraints,
subset/exclusion/equality constraints and external uniqueness have no schema-level equivalent.
Rather than dropping them, Factum tracks exactly which constraints the generated schema really enforces and verbalizes the rest into the script:
// ---------------------------------------------------------------------------
// Constraints the schema cannot enforce. LadybugDB checks primary keys and
// relationship multiplicities; the rules below must be upheld by the
// application or by a validation query.
// ---------------------------------------------------------------------------
// [mandatory] It is necessary that each Person works for some Company.
// [uniqueness] In each population of "Person has Skill", each Person, Skill
// combination occurs at most once.
// [value] It is necessary that the possible values of GenderCode are {'M', 'F'}.
MANY_MANY permits duplicate edges between the same
pair of nodes, so a spanning uniqueness constraint is not enforced by the schema. The same
list appears under Not enforced by the schema in the Graph tab.