Migrations
The Ontopoiesis migration workflow treats an ontology as the product of a sequence of versioned, idempotent Cypher scripts rather than a serialized file to be edited in place. Scripts are applied in order against a projection database — the same way Alembic or Flyway manage database schema.
The migration model¶
Each migration is a numbered .cypher file in a migrations/ directory.
The runner applies them in lexicographic order and records each successful script in
the projection database. The runner skips scripts whose ids have already been applied,
so re-running against the same projection is safe. Each file runs as a single transaction: if any
statement fails, none of that migration's writes are committed and the runner exits
with a non-zero status. Fix the failing statement and re-run — already-applied
scripts are skipped, so only the failed migration is retried.
migrations/
V0001__init_ontology.cypher
V0002__class_hierarchy.cypher
V0003__properties.cypher
V0004__individuals.cypher
Each file is a .cypher script with an optional leading comment block and one or
more Cypher statements separated by semicolons. Migration identity comes from the
filename, so comments are just comments. The runner renders each file through the
Jinja environment before execution, so you can use template helpers and shared macros
directly inside migration scripts.
Relationships carry a role property naming the structural field
(e.g. sub, super, individual, axiom) and a position giving the child's
zero-based order under its parent. All constructs live in the single node table N and
all semantic references in the single relationship table E.
Worked example: Margherita pizza¶
The four scripts below build the same ontology represented in pizza.owlxml,
arriving at it incrementally.
V0001 — Declare the ontology¶
// Declare the pizza ontology root and document prefixes.
// Every E edge carries an integer `position` (document order under its
// parent); `role` is query-facing decoration. Ontology child positions are
// assigned globally across migrations so the document order stays stable.
// Ontology(<https://example.org/pizza#>)
MERGE (ontology:N {uid: '0x01', kind: 'Ontology', ontology_iri: 'https://example.org/pizza#'})
MERGE (defaultPrefix:N {uid: '0x03', kind: 'Prefix', prefix_name: '', iri: 'https://example.org/pizza#'})
MERGE (owlPrefix:N {uid: '0x04', kind: 'Prefix', prefix_name: 'owl', iri: 'http://www.w3.org/2002/07/owl#'})
MERGE (rdfPrefix:N {uid: '0x05', kind: 'Prefix', prefix_name: 'rdf', iri: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'})
MERGE (xmlPrefix:N {uid: '0x06', kind: 'Prefix', prefix_name: 'xml', iri: 'http://www.w3.org/XML/1998/namespace'})
MERGE (xsdPrefix:N {uid: '0x07', kind: 'Prefix', prefix_name: 'xsd', iri: 'http://www.w3.org/2001/XMLSchema#'})
MERGE (rdfsPrefix:N {uid: '0x08', kind: 'Prefix', prefix_name: 'rdfs', iri: 'http://www.w3.org/2000/01/rdf-schema#'})
MERGE (ontology)-[:E {role: 'prefix', position: 0}]->(defaultPrefix)
MERGE (ontology)-[:E {role: 'prefix', position: 1}]->(owlPrefix)
MERGE (ontology)-[:E {role: 'prefix', position: 2}]->(rdfPrefix)
MERGE (ontology)-[:E {role: 'prefix', position: 3}]->(xmlPrefix)
MERGE (ontology)-[:E {role: 'prefix', position: 4}]->(xsdPrefix)
MERGE (ontology)-[:E {role: 'prefix', position: 5}]->(rdfsPrefix);
V0002 — Class hierarchy¶
Why
MERGE, notCREATE: Migration scripts are meant to be replay-safe.MERGEis idempotent: if you re-run the same migration, it matches the existing construct instead of creating a duplicate. BareCREATEalways inserts a fresh node or edge, which is why re-running a migration that usesCREATEcan silently duplicate ontology structure.
// Pizza and Topping classes with one subclass axiom.
// Ontology from earlier migration
MATCH (ontology:N {uid: '0x01'})
// Class(:Pizza), Class(:Topping), Class(:MargheritaPizza)
MERGE (Pizza:N {uid: '0x10', kind: 'Class', iri: 'https://example.org/pizza#Pizza'})
MERGE (Topping:N {uid: '0x11', kind: 'Class', iri: 'https://example.org/pizza#Topping'})
MERGE (MargheritaPizza:N {uid: '0x12', kind: 'Class', iri: 'https://example.org/pizza#MargheritaPizza'})
// Declaration(Class(:Pizza))
MERGE (declPizza:N {uid: '0x20', kind: 'Declaration'})
MERGE (declPizza)-[:E {role: 'entity', position: 0}]->(Pizza)
MERGE (ontology)-[:E {role: 'axiom', position: 6}]->(declPizza)
// Declaration(Class(:Topping))
MERGE (declTopping:N {uid: '0x21', kind: 'Declaration'})
MERGE (declTopping)-[:E {role: 'entity', position: 0}]->(Topping)
MERGE (ontology)-[:E {role: 'axiom', position: 7}]->(declTopping)
// Declaration(Class(:MargheritaPizza))
MERGE (declMargherita:N {uid: '0x22', kind: 'Declaration'})
MERGE (declMargherita)-[:E {role: 'entity', position: 0}]->(MargheritaPizza)
MERGE (ontology)-[:E {role: 'axiom', position: 8}]->(declMargherita)
// SubClassOf(:MargheritaPizza :Pizza)
MERGE (subClassOf:N {uid: '0x23', kind: 'SubClassOf'})
MERGE (subClassOf)-[:E {role: 'sub', position: 0}]->(MargheritaPizza)
MERGE (subClassOf)-[:E {role: 'super', position: 1}]->(Pizza)
MERGE (ontology)-[:E {role: 'axiom', position: 9}]->(subClassOf);
V0003 — Object property¶
// hasTopping property with domain Pizza and range Topping.
// Ontology and classes from earlier migrations
MATCH
(ontology:N {uid: '0x01'}),
(Pizza:N {uid: '0x10'}),
(Topping:N {uid: '0x11'})
// ObjectProperty(:hasTopping)
MERGE (hasTopping:N {uid: '0x30', kind: 'ObjectProperty', iri: 'https://example.org/pizza#hasTopping'})
// Declaration(ObjectProperty(:hasTopping))
MERGE (declHasTopping:N {uid: '0x31', kind: 'Declaration'})
MERGE (declHasTopping)-[:E {role: 'entity', position: 0}]->(hasTopping)
MERGE (ontology)-[:E {role: 'axiom', position: 10}]->(declHasTopping)
// ObjectPropertyDomain(:hasTopping :Pizza)
MERGE (objectPropertyDomain:N {uid: '0x32', kind: 'ObjectPropertyDomain'})
MERGE (objectPropertyDomain)-[:E {role: 'property', position: 0}]->(hasTopping)
MERGE (objectPropertyDomain)-[:E {role: 'domain', position: 1}]->(Pizza)
MERGE (ontology)-[:E {role: 'axiom', position: 11}]->(objectPropertyDomain)
// ObjectPropertyRange(:hasTopping :Topping)
MERGE (objectPropertyRange:N {uid: '0x33', kind: 'ObjectPropertyRange'})
MERGE (objectPropertyRange)-[:E {role: 'property', position: 0}]->(hasTopping)
MERGE (objectPropertyRange)-[:E {role: 'range', position: 1}]->(Topping)
MERGE (ontology)-[:E {role: 'axiom', position: 12}]->(objectPropertyRange);
V0004 — Individuals and assertions¶
// The margherita individual and its three toppings.
// Ontology, classes, and properties from earlier migrations
MATCH
(ontology:N {uid: '0x01'}),
(Topping:N {uid: '0x11'}),
(MargheritaPizza:N {uid: '0x12'}),
(hasTopping:N {uid: '0x30'})
// NamedIndividual(:margherita), NamedIndividual(:tomatoSauce), NamedIndividual(:mozzarella), NamedIndividual(:basil)
MERGE (margherita:N {uid: '0x40', kind: 'NamedIndividual', iri: 'https://example.org/pizza#margherita'})
MERGE (tomatoSauce:N {uid: '0x41', kind: 'NamedIndividual', iri: 'https://example.org/pizza#tomatoSauce'})
MERGE (mozzarella:N {uid: '0x42', kind: 'NamedIndividual', iri: 'https://example.org/pizza#mozzarella'})
MERGE (basil:N {uid: '0x43', kind: 'NamedIndividual', iri: 'https://example.org/pizza#basil'})
// Declaration(NamedIndividual(:margherita))
MERGE (declMargherita:N {uid: '0x50', kind: 'Declaration'})
MERGE (declMargherita)-[:E {role: 'entity', position: 0}]->(margherita)
MERGE (ontology)-[:E {role: 'axiom', position: 13}]->(declMargherita)
// Declaration(NamedIndividual(:tomatoSauce))
MERGE (declTomatoSauce:N {uid: '0x51', kind: 'Declaration'})
MERGE (declTomatoSauce)-[:E {role: 'entity', position: 0}]->(tomatoSauce)
MERGE (ontology)-[:E {role: 'axiom', position: 14}]->(declTomatoSauce)
// Declaration(NamedIndividual(:mozzarella))
MERGE (declMozzarella:N {uid: '0x52', kind: 'Declaration'})
MERGE (declMozzarella)-[:E {role: 'entity', position: 0}]->(mozzarella)
MERGE (ontology)-[:E {role: 'axiom', position: 15}]->(declMozzarella)
// Declaration(NamedIndividual(:basil))
MERGE (declBasil:N {uid: '0x53', kind: 'Declaration'})
MERGE (declBasil)-[:E {role: 'entity', position: 0}]->(basil)
MERGE (ontology)-[:E {role: 'axiom', position: 16}]->(declBasil)
// ClassAssertion(:MargheritaPizza :margherita)
MERGE (classAssertionMargherita:N {uid: '0x60', kind: 'ClassAssertion'})
MERGE (classAssertionMargherita)-[:E {role: 'class', position: 0}]->(MargheritaPizza)
MERGE (classAssertionMargherita)-[:E {role: 'individual', position: 1}]->(margherita)
MERGE (ontology)-[:E {role: 'axiom', position: 17}]->(classAssertionMargherita)
// ClassAssertion(:Topping :tomatoSauce)
MERGE (classAssertionTomato:N {uid: '0x61', kind: 'ClassAssertion'})
MERGE (classAssertionTomato)-[:E {role: 'class', position: 0}]->(Topping)
MERGE (classAssertionTomato)-[:E {role: 'individual', position: 1}]->(tomatoSauce)
MERGE (ontology)-[:E {role: 'axiom', position: 18}]->(classAssertionTomato)
// ClassAssertion(:Topping :mozzarella)
MERGE (classAssertionMozzarella:N {uid: '0x62', kind: 'ClassAssertion'})
MERGE (classAssertionMozzarella)-[:E {role: 'class', position: 0}]->(Topping)
MERGE (classAssertionMozzarella)-[:E {role: 'individual', position: 1}]->(mozzarella)
MERGE (ontology)-[:E {role: 'axiom', position: 19}]->(classAssertionMozzarella)
// ClassAssertion(:Topping :basil)
MERGE (classAssertionBasil:N {uid: '0x63', kind: 'ClassAssertion'})
MERGE (classAssertionBasil)-[:E {role: 'class', position: 0}]->(Topping)
MERGE (classAssertionBasil)-[:E {role: 'individual', position: 1}]->(basil)
MERGE (ontology)-[:E {role: 'axiom', position: 20}]->(classAssertionBasil)
// ObjectPropertyAssertion(:hasTopping :margherita :tomatoSauce)
MERGE (hasToppingTomato:N {uid: '0x70', kind: 'ObjectPropertyAssertion'})
MERGE (hasToppingTomato)-[:E {role: 'property', position: 0}]->(hasTopping)
MERGE (hasToppingTomato)-[:E {role: 'subject', position: 1}]->(margherita)
MERGE (hasToppingTomato)-[:E {role: 'object', position: 2}]->(tomatoSauce)
MERGE (ontology)-[:E {role: 'axiom', position: 21}]->(hasToppingTomato)
// ObjectPropertyAssertion(:hasTopping :margherita :mozzarella)
MERGE (hasToppingMozzarella:N {uid: '0x71', kind: 'ObjectPropertyAssertion'})
MERGE (hasToppingMozzarella)-[:E {role: 'property', position: 0}]->(hasTopping)
MERGE (hasToppingMozzarella)-[:E {role: 'subject', position: 1}]->(margherita)
MERGE (hasToppingMozzarella)-[:E {role: 'object', position: 2}]->(mozzarella)
MERGE (ontology)-[:E {role: 'axiom', position: 22}]->(hasToppingMozzarella)
// ObjectPropertyAssertion(:hasTopping :margherita :basil)
MERGE (hasToppingBasil:N {uid: '0x72', kind: 'ObjectPropertyAssertion'})
MERGE (hasToppingBasil)-[:E {role: 'property', position: 0}]->(hasTopping)
MERGE (hasToppingBasil)-[:E {role: 'subject', position: 1}]->(margherita)
MERGE (hasToppingBasil)-[:E {role: 'object', position: 2}]->(basil)
MERGE (ontology)-[:E {role: 'axiom', position: 23}]->(hasToppingBasil);
Running the migrations¶
By default this writes migrations.lbug next to the migration directory. Pass
--output to choose a different projection path:
The runner reports each script it applies in order and prints a node/edge summary on completion:
Applied V0001
Applied V0002
Applied V0003
Applied V0004
╭─ Migrate Complete ─╮
│ nodes 33 │
│ edges 55 │
╰────────────────────╯
Re-running the directory against the same projection is safe — the runner reads
applied migration ids from the projection and skips scripts it has already recorded.
Within one migration directory, every migration id must be unique. If two filenames
share the same id prefix, the runner rejects the directory before applying any scripts.
Pass --force to discard an existing output projection and rebuild it from scratch.
Exporting the result¶
If you need an OWL document after running the migrations, export the .lbug
projection in a second step:
ontopoiesis migrate migrations/ --output pizza.lbug
ontopoiesis export pizza.lbug --output pizza_rebuilt.owlxml
This example makes the document root explicit by creating the Ontology node and its
Prefix constructs in V0001.
For the content-addressing model that makes migration MERGE operations idempotent,
see The Projection Graph Model.
Extending the ontology¶
The normal workflow is incremental: keep the existing projection, add one new numbered migration file, and re-run the directory. The runner records applied migration ids in the projection and skips them on the next run, so only the new script is applied.
Add V0005__pizza_toppings.cypher:
{%- set topping_uid = scalar_uid("Class", "https://example.org/PizzaTopping") -%}
{%- set pizza_uid = scalar_uid("Class", "https://example.org/Pizza") -%}
{%- set ax_uid = axiom_uid("SubClassOf", [
("sub", topping_uid),
("super", pizza_uid)
]) -%}
MERGE (topping:N {uid: "<< topping_uid >>"})
SET topping.kind = "Class", topping.iri = "https://example.org/PizzaTopping";
MERGE (pizza:N {uid: "<< pizza_uid >>"})
SET pizza.kind = "Class", pizza.iri = "https://example.org/Pizza";
MERGE (ax:N {uid: "<< ax_uid >>"})
SET ax.kind = "SubClassOf";
MERGE (ax)-[:E {role: "sub"}]->(topping);
MERGE (ax)-[:E {role: "super"}]->(pizza);
Then re-run the directory against the existing projection:
This applies V0005 and leaves V0001 through V0004 untouched. Express corrections
as new forward migrations rather than by editing old files that may already have been
recorded in existing projections.
Cypher template helpers¶
The migration runner processes .cypher files through a Jinja2 environment before
execution. Use template helpers to keep migration UIDs deterministic and to share
repeated OWL graph patterns across files.
Template globals¶
The environment exposes two UID helpers as globals. Using them in templates makes
migrations replay-safe: the same inputs always produce the same UID, so MERGE
statements are idempotent across runs. The helpers use the existing migration UID
namespaces and a 128-bit SHA-256 digest prefix; scalar constructs and axiom-shaped
constructs intentionally live in separate namespaces.
| Function | Parameters | Purpose |
|---|---|---|
scalar_uid(kind, iri) |
kind: OWL 2 construct kind string; iri: expanded IRI string |
Deterministic UID for a named entity |
axiom_uid(kind, edges) |
kind: construct kind; edges: ordered list of (role, child_uid) pairs |
Deterministic UID for an axiom by structure |
Both functions return a 0x-prefixed UID with 32 lowercase hex digest characters.
The UID is stable for migration-authored constructs across re-runs and across machines.
To verify a UID matches an existing projection node, query with:
scalar_uid computes the UID for a named entity from its kind and expanded IRI:
{%- set person_uid = scalar_uid("Class", "http://example.org/Person") -%}
MERGE (n:N {uid: "<< person_uid >>"})
SET n.kind = "Class", n.iri = "http://example.org/Person";
axiom_uid computes the UID for an axiom or anonymous expression from its kind and
the ordered list of (role, child_uid) pairs that describe its structure:
{%- set person_uid = scalar_uid("Class", "http://example.org/Person") -%}
{%- set animal_uid = scalar_uid("Class", "http://example.org/Animal") -%}
{%- set ax_uid = axiom_uid("SubClassOf", [
("sub", person_uid),
("super", animal_uid)
]) -%}
MERGE (ax:N {uid: "<< ax_uid >>"}) SET ax.kind = "SubClassOf";
MERGE (p:N {uid: "<< person_uid >>"}) SET p.kind = "Class", p.iri = "http://example.org/Person";
MERGE (a:N {uid: "<< animal_uid >>"}) SET a.kind = "Class", a.iri = "http://example.org/Animal";
MERGE (ax)-[:E {role: "sub"}]->(p);
MERGE (ax)-[:E {role: "super"}]->(a);
The role names in axiom_uid must match the role values used on projection edges
(sub, super, operand, …). See the
constructs reference for the full list.
Constraints¶
iri must be a fully expanded IRI string. Do not pass a prefixed form such as
foaf:Person. A prefixed IRI produces a different UID than the one ontopoiesis build
assigned, so MERGE creates a duplicate node instead of targeting the existing one.
Role names in axiom_uid must be in the correct order. The UID derives from the
sequence of (role, child_uid) pairs in edges, so the order matters.
kind must exactly match the projection kind string. The kind strings are the OWL 2
structural specification construct names: SubClassOf, ObjectProperty, Class, and
so on.
Macros¶
Jinja2 macros let repeated Cypher patterns be defined once and called by name.
Example — subclass_axioms.cypher¶
This template defines a subclass macro that generates an idempotent SubClassOf
axiom from two IRIs:
{%- macro subclass(sub_iri, super_iri) %}
MATCH
(sub:N {iri: '<< sub_iri >>'}),
(super:N {iri: '<< super_iri >>'})
MERGE (axiom:N {
uid: '<< axiom_uid("SubClassOf", [("sub", scalar_uid("Class", sub_iri)),
("super", scalar_uid("Class", super_iri))]) >>',
kind: 'SubClassOf'
})
MERGE (axiom)-[:E {role: 'sub', position: 0}]->(sub)
MERGE (axiom)-[:E {role: 'super', position: 1}]->(super)
{%- endmacro %}
// Assert the pizza class hierarchy.
<< subclass("https://example.org/pizza#MargheritaPizza", "https://example.org/pizza#Pizza") >>;
<< subclass("https://example.org/pizza#Pizza", "https://www.w3.org/2002/07/owl#Thing") >>;
Sharing macros across migrations¶
A simple project layout is:
owl.cypher defines shared macros such as subclass. Individual migrations then
import them:
{%- from 'owl.cypher' import subclass -%}
<< subclass("ex:Animal", "owl:Thing") >>;
<< subclass("ex:Person", "ex:Animal") >>;
The Jinja environment resolves imports relative to the migrations directory, so a shared macro library can live beside the numbered migration files.
Expression delimiters¶
Block tags use {% %} and expressions use << >> — both are invalid in Cypher, so
no escaping is needed in query files.
Troubleshooting¶
A migration fails partway through. Each migration file runs as a single transaction. If any statement in the file fails, none of the file's writes are committed. The runner exits non-zero and prints the failing statement. Fix the statement in the migration file and re-run the directory — already-applied scripts are skipped by migration id, so only the failed migration is retried.
The projection has unexpected nodes after re-running. All migration statements
should use MERGE rather than CREATE so that re-applying a script produces the same
graph state. If a script uses bare CREATE, re-running it will add duplicate nodes.
Replace CREATE with MERGE and add ON MATCH SET clauses if you need to update
properties on existing nodes.
MERGE is not matching the expected node. The uid used in a MERGE key must
be stable for the construct you intend to update. Use the scalar_uid(kind, iri) and
axiom_uid(kind, edges) template functions to compute deterministic migration UIDs
rather than hardcoding hash-like values. See Cypher template helpers
for the UID helper reference and namespace contract.
The runner reports a duplicate migration id. Migration identity comes from the
filename prefix before __, so V0002__classes.cypher and
V0002__properties.cypher are the same migration id. Rename one file to the next
unused id before running the directory again.
Where To Go Next¶
With a migration-built projection in hand, the rest of the Ontopoiesis workflow applies without modification:
- Queries — interrogate the projection with Cypher to verify the ontology structure is what you intended
- Tests — write structural assertions as
.cypherfiles and run them as CI gates - Lint — run the built-in baseline against the migration output to catch structural contradictions and redundancies
- Diff — compare projections built from different migration checkpoints to see exactly which axioms changed between versions
- Rendering — produce SVG diagrams of construct neighborhoods for documentation or review