Seven targets, and what each one refuses to promise
Every generator publishes a typed capability set. The compiler works out what your model requires, compares the two, and reports every gap — as an editor diagnostic and as a comment at the lossy site in the artifact.
required constraint that quietly vanishes is a data-integrity bug that surfaces in production, long after the model stopped being read.ladybug
LadybugDB — formerly Kuzu — is an embedded Cypher property graph database with a mandatory
closed schema: CREATE NODE TABLE and CREATE REL TABLE, typed
columns, a required primary key, and declared endpoint pairs. Output extension:
.cypher.
How the hierarchy lands
An abstract hierarchy is flattened to one node table per concrete leaf type, with inherited columns copied down. The abstract type itself gets no table. The cost is that an edge declared on an abstract endpoint expands to a cross-product of endpoint pairs, and adding a subtype becomes a schema migration.
What it will not enforce
Measured against LadybugDB 0.19.1, the engine enforces less than the flattening suggests:
NOT NULLis not accepted by the parser, and a null non-key value inserts successfully — so a required property that is not the key is unenforceable, and is reported as a downgrade.- A composite
PRIMARY KEYdoes not parse either, so a composite key is emitted as a synthesized concatenated column. - Only primary key uniqueness and primary key presence are actually enforced.
These are measured, not assumed. The Ladybug generator executes its own
output against an in-process LadybugDB instance and then asserts that the declared
constraints really do reject invalid data. That is how the -- versus
// comment syntax was discovered: the SQL-style form is rejected by the parser,
which a golden file alone would never have caught.
neo4j
Neo4j is schema-optional: there is no table DDL, only constraints and indexes. Multi-label
nodes are native, so an abstract hierarchy flattens to labels rather than to separate
tables — a Person node carries :Person :Party. Output extension:
.cypher.
Edition awareness
Existence and node key constraints require Enterprise. Under a Community configuration they are reported as downgrades and emitted as comments rather than silently dropped — a node key becomes a plain uniqueness constraint, which does not enforce that the key is present.
// Setting: lpg.targets.neo4j.edition
// DOWNGRADE: NODE KEY requires Enterprise.
// Uniqueness only; presence unenforced.
CREATE CONSTRAINT person_key_unique IF NOT EXISTS
FOR (n:Person) REQUIRE (n.id) IS UNIQUE;
Set the editions setting to enterprise, or pass --edition enterprise to the CLI, and the real constraints are emitted instead.
shacl
SHACL is the primary constraint artifact for RDF, because it is closed-world validation and
so means what a property graph schema means. Required, unique, cardinality and datatype all
translate faithfully, and a generated shape genuinely rejects invalid data. Output
extension: .ttl.
The one gap: uniqueness across all instances needs a SPARQL-based constraint, which core SHACL cannot express. It is reported as a downgrade with a comment on the affected property shape.
owl
The ontology is emitted alongside SHACL, not instead of it, because a property graph schema
and an OWL ontology do not mean the same thing. A schema is a closed-world constraint; OWL
is open-world inference. Output extension: .ttl.
The safe assertional subset
Only classes, subClassOf, hasKey, disjointness and inverse
properties are emitted. Domain, range and cardinality restrictions are deliberately omitted.
Why omitting them is the careful choice. Emitting
rdfs:domain for an edge type does not constrain anything — it instructs a
reasoner that anything carrying that relation belongs to the domain class, silently
reclassifying unrelated individuals. Mapping constraints naively into OWL does not lose
information so much as invert its meaning. The constraints live in the SHACL artifact, and
every omission here is reported with a diagnostic pointing at it.
Gradual reification
An edge with no properties becomes a plain object property. An edge that carries properties becomes an n-ary relation class plus a shortcut property, and its SHACL shape targets that class.
Standards targets
Three targets exist so a model is readable by tools this project does not own. None is a database dialect: they are the schema languages the graph world has agreed on, and emitting them is how a model authored here stays portable.
Why emit a standard rather than adopt one. No published standard covers what a model file has to do. PG-Schema and GQL graph types are textual DSLs with nowhere to hang stable element ids, import aliases, or a rename’s previous IRI, and neither has a namespace concept the RDF targets need. LinkML is the closest serialization, but it has no binary edge that can carry properties — adopting it would force the uniform reification that gradual reification exists to avoid. So the YAML stays, and the standards come out of the pipeline instead.
gql
GQL graph types, per ISO/IEC 39075 — the international standard for
graph query languages. A graph type is a list of element types, each naming an identifying
label, the labels it implies, and its typed properties. Output extension: .gql.
CREATE GRAPH TYPE socialGraphType AS {
(personType: Person => :Party {
email :: STRING NOT NULL IS NODE UNIQUE,
id :: STRING NOT NULL IS NODE KEY
}),
(:Party)-[ownsType: OWNS => {
since :: DATE
}]->(:Car)
}
Label implication carries the hierarchy, so an edge declared on an abstract endpoint stays a single element type instead of expanding to a cross-product the way the Ladybug target must. Abstract types get no element type of their own — they exist purely as implied labels.
Two things are lost, both reported. A key marker attaches to one property, so a composite key
cannot be expressed; and uuid and json have no GQL value type.
Engines also disagree on the statement that installs a graph type — Neo4j writes the same
body after ALTER CURRENT GRAPH TYPE SET — so the generated file says so in a
header comment rather than claiming portability it does not have.
pgschema
PG-Schema, the formalism from the LDBC Property Graph Schema Working Group
that GQL’s graph types grew out of. This is the most faithful target of the seven:
ABSTRACT, inheritance, mixins and keys all have direct counterparts, so nothing
about the hierarchy is flattened. Output extension: .pgs.
CREATE GRAPH TYPE socialType STRICT {
ABSTRACT (timestampedType {createdAt ZONED DATETIME}),
ABSTRACT (partyType: Party {id STRING}),
(personType: partyType & timestampedType & Person {email STRING, OPTIONAL born DATE}),
(:partyType)-[ownsType: OWNS {OPTIONAL since DATE}]->(:carType),
FOR (x: partyType) EXCLUSIVE MANDATORY SINGLETON x.id
}
A mixin becomes an abstract type declared without a label, which is exactly what a mixin is
here. Keys become PG-Keys constraints, stated once on the type that owns them because
subtypes inherit them, and a composite key needs no synthesized column. Only
uuid and json are downgrades.
linkml
LinkML, the linked-data modelling language. Its classes, is_a
and mixins line up almost directly with this metamodel, which is what makes the
target worth having: it opens the whole LinkML generator ecosystem — Python, Pydantic,
TypeScript, GraphQL, SQL DDL, SQLAlchemy — to a model authored here. Output extension:
.yaml.
classes:
Party:
class_uri: social:Party
abstract: true
attributes:
id:
slot_uri: social:id
range: string
identifier: true
Person:
is_a: Party
mixins:
- Timestamped
Every class and slot carries the IRI it has in this model, so identity survives the round
trip rather than degrading to a local name. A single key becomes an identifier;
a composite key and every other unique property become unique_keys, the only
mechanism LinkML has for them.
The one real mismatch. LinkML has no binary relation that can hold properties, so an edge that carries them is reified into a class — and that is reported as a downgrade like any other. The shortcut property the RDF targets emit is deliberately left out here: in a schema meant to be generated from, it would imply a second place the same fact is written.
Targets that are deliberately absent
Cypher compatibility is a marketing category rather than a dialect: Ladybug, Neo4j, Memgraph and Apache AGE disagree on nearly everything schema-related, so a generic Cypher generator would have no reference implementation to test against.
The rule is about reference implementations rather than running engines, which is why the standards targets ship as code despite having no instance to execute against: a published specification is a reference a generator can be held to, and a dialect nobody has specified is not.
For a dialect that cannot be tested here, the intended path is a template fed by the resolved model rather than a hand-written generator — so an additional target is a piece of configuration rather than a feature request. That, along with migrations and the lockfile diff, is not in this release.
Adding a target
Generators sit behind an internal registry: a target is one file plus one entry. The public plugin API is deliberately deferred until three real generators have shown where the seam actually falls — the capability set on this page is what that API will eventually expose.
interface Capabilities {
target: string
multiLabel: boolean
inheritance: 'leaf-tables' | 'labels' | 'subclass'
requiredConstraint: 'enforced' | 'key-only' | 'edition-dependent' | 'unsupported'
uniqueConstraint: 'enforced' | 'key-only' | 'edition-dependent' | 'unsupported'
compositeKey: 'native' | 'synthesized' | 'unsupported'
edgeProps: 'native' | 'reified'
nestedEdges: boolean
listProps: 'native' | 'unsupported'
enums: 'enforced' | 'documented' | 'unsupported'
openTypes: 'native' | 'always-open' | 'unsupported'
cardinality: 'enforced' | 'unsupported'
}
A downgrade is reported only when a model actually uses the feature. A target that cannot enforce closure declares that in its capability set, but does not raise a diagnostic on every closed type — that would be noise on every model rather than information.