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 roles NULL;
  • 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 CHECK constraints.

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 SERIAL key, 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 BOOLEAN property;
  • an n-ary or objectified fact type is reified into a node with one MANY_ONE relationship 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 ORMMultiplicityReads as
On the from role onlyMANY_ONEEach Person works for at most one Company
On the to role onlyONE_MANYEach Company employs at most one Person
On both rolesONE_ONEA one-to-one correspondence
Spanning both rolesMANY_MANYNeither 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 an IS_A relationship. 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'}.
That second one is easy to miss: 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.