ERM DSL Reference
ERM (Entity-Relationship Modelling) is kUML’s dedicated language for database schema design — a first-class metamodel alongside UML 2.x, SysML 2, C4, and BPMN 2.0, not a UML profile bolted on top of class diagrams. The ERM model is the single source of truth; diagrams are projections that pick a notation (Martin/crow’s-foot, Bachman, Chen, or IDEF1X) without changing the underlying entities and relationships.
ERM sits at the centre of kUML’s database-facing MDA chain: rather than generating SQL
directly from a UML class diagram, kUML derives an explicit ERM model first
(UML → ERM) and only then generates SQL DDL or Kotlin Exposed tables (ERM → SQL /
ERM → Exposed). This makes mapping decisions — inheritance strategy, M:N junction
tables, FK naming — visible and overridable as an inspectable model, instead of hidden
inside a code generator. You can also author an ERM model directly, without any UML
detour, when no UML model exists for the schema you are designing.
The ERM model
An ERM model is built with the ermModel(name) { … } entry point — the same pattern as
classDiagram { … }, c4Model { … }, or bpmnModel { … }. Auto-generated element ids
are deterministic (entity_0, rel_1, view_2, …), never random UUIDs, so scripts stay
stable across re-runs and diff cleanly.
If the script declares no diagram(…) call, build() synthesizes a default one (Martin
notation, projecting the whole model) — every other kUML DSL requires at least one
diagram, and ERM follows the same convention.
id() is a convenience for a not-null primary-key column (UUID by default);
foreignKey(name, references, onDelete, nullable) declares a column whose type is
inferred from the target entity’s (single-column) primary key. Note that
foreignKey(…) only declares the column — it does not draw an edge in the diagram.
The rendered relationship comes from a separate relationship(…) call (or one of the
infix helpers below); the ERM layout bridge reads model.relationships, not
ErmForeignKey references.
First-class elements
Unlike UML-based persistence workarounds that bolt table metadata onto class diagrams via stereotypes, ERM treats views, indexes, and check constraints as first-class model elements — not tagged values:
| Builder | Purpose |
|---|---|
|
Declares a table. |
|
General column declaration. |
|
Index over already-declared columns of the enclosing entity, resolved by name. |
|
A |
|
A first-class database view — raw |
|
IDEF1X subtype/supertype cluster — see the IDEF1X section below. |
Data types (ErmDataType, sealed): Integer(bits), Decimal(precision, scale),
Real(double), Varchar(length), Text, Boolean, Date, Time,
Timestamp(withTimeZone), Uuid, Blob, Json, and Custom(raw) as an escape hatch
for dialect-specific types with no logical equivalent (e.g. Postgres tsvector, or a
PostGIS geometry(…) column — see PostGIS geometry and TimescaleDB hypertables).
Relationships and cardinalities
relationship(from, to, name, sourceCardinality, targetCardinality, kind, sourceRole,
targetRole) draws the rendered edge between two entities (by id). kind is
RelationshipKind.NON_IDENTIFYING (default, a plain foreign key) or IDENTIFYING (the
child’s primary key includes the parent’s key through this relationship — typically
paired with a weak entity).
Three infix helpers cover the common cardinality shapes and return the target id for chaining:
| Infix | Cardinality |
|---|---|
|
source |
|
source |
|
source |
sourceRole / targetRole name the ends of a relationship — useful when the same pair
of entities is connected by more than one relationship (e.g. an Order with separate
billing/shipping foreign keys into Address, each needing its own labelled edge).
Notations
The same model renders in four notations by setting notation on diagram(…) — no
change to entities, attributes, or relationships. All four are implemented end to end
(model → layout → SVG renderer).
Martin ("crow’s foot") is the default and most widely recognised notation — cardinality
glyphs (○, |, crow’s foot) sit directly on the connecting line.
Bachman uses directed arrows rather than crow’s-foot glyphs — a style still common in older CASE tools and some database-design textbooks.
Chen notation is structurally distinct from the other three: attributes render as ovals attached to their entity, and relationships render as their own diamond-shaped nodes rather than plain edges — the original 1976 Chen ER notation.
IDEF1X draws identifying relationships as solid lines and non-identifying relationships
as dashed lines, rounds the corners of dependent (weak) entities, and adds
subtype/supertype category symbols via category(…).
CLI override — pick a notation at render time without touching the script:
kuml render schema.kuml.kts --notation martin
kuml render schema.kuml.kts --notation bachman
kuml render schema.kuml.kts --notation chen
kuml render schema.kuml.kts --notation idef1x
--notation overrides whatever notation = … the script’s diagram(…) call set; it is
ignored for non-ERM diagrams.
Full example
A complete schema exercising every first-class element — entity, weak entity,
identifying relationship, foreign key, index, check constraint, and view (this is the
kUML CLI’s own test fixture, kuml-cli/src/test/resources/erm/valid-ecommerce.kuml.kts):
A larger, neutral e-commerce schema covering multiple foreign keys into the same table,
a self-reference, and three foreign keys from a single entity is available as a vault
worked example: 03 Bereiche/kUML/Beispiele/39 ERM Martin – E-Commerce Schema.md.
From UML: the UML→ERM transform
An ERM model can be derived from an existing UML class diagram instead of being written by hand — useful when a domain model already exists in UML and a database schema needs to be designed from it:
kuml transform model.kuml.kts --transformer uml-to-erm-script --output gen/
Transformer id uml-to-erm-script (UmlToErmScriptTransformer) emits .kuml.kts DSL
source text; the lower-level uml-to-erm id (UmlToErmTransformer) returns the
in-memory ErmModel directly for chaining into another transformer or generator.
Mapping decisions that would otherwise be implicit — inheritance strategy, FK naming
on collision, junction-table names for M:N associations — are overridable through the
ERM mapping profile (kuml-profile-erm) applied as tagged values on the source UML
model, the same pattern used by the other built-in profiles (see
Profiles).
To SQL and Exposed: codegen
Two code generators consume an ERM model — both discoverable through the same
kuml generate --plugin <id> entry point used by the Kotlin, Java, and SQL generators
described in Code generation:
| Plugin id | Output |
|---|---|
|
SQL DDL via |
|
Kotlin Exposed |
Both plugins accept an ERM model directly (ermModel(…) { … } script) or a UML model
chained through the transform first:
# ERM-first — schema authored directly in ERM
kuml generate --input schema.kuml.kts --plugin sql --options sql-dialect=postgres --output gen/
kuml generate --input schema.kuml.kts --plugin exposed --output gen/
# UML-first — chain UML → ERM → Exposed in one step
kuml transform model.kuml.kts --transformer uml-to-exposed-via-erm --output gen/
The older UML-direct transformers (uml-to-exposed, uml-to-exposed-psm) remain
runnable but are @Deprecated — erm-to-exposed (ERM input) and
uml-to-exposed-via-erm (UML input, chained through ERM) are the recommended path
going forward, since the ERM intermediate model makes the mapping decisions inspectable
as a diagram instead of hidden inside the generator.
PostGIS geometry and TimescaleDB hypertables
Since v0.30.0, the sql and exposed generators recognize two Postgres-ecosystem
extensions straight from the ERM model — without a new ErmDataType variant or a runtime
plugin (ADR-0016 §2.3). Both hooks are deliberately narrow: no general GIS type system, no
ServiceLoader/plugin SPI, and no automatic hypertable inference.
PostGIS geometry columns
A column typed ErmDataType.Custom("geometry(<kind>[,<SRID>])") is recognized as a PostGIS
geometry column. <kind> is one of Point, LineString, Polygon, or Geometry
(case-insensitive); the SRID is an optional integer (e.g. 4326).
On the sql plugin with sql-dialect=postgres, the column is normalized to its canonical
Postgres type; other SQL dialects pass the raw string through unchanged:
location geometry(Point,4326) NOT NULL
The exposed plugin renders a dependency-free geometry(name, sqlType) column call, backed by
a generated PostGisColumnTypes.kt support file (emitted only when at least one geometry column
is present) — no exposed-spatial/PostGIS runtime dependency:
public val location: Column<String> = geometry("location", "geometry(Point,4326)")
TimescaleDB hypertables
hypertable(timeColumn, chunkInterval?) on an entity marks it as a TimescaleDB hypertable.
timeColumn must name a column declared on the same entity (validated at emission time);
chunkInterval is an optional free-text interval such as "7 days".
On the sql plugin with sql-dialect=postgres, a create_hypertable(…) call is emitted
right after CREATE TABLE (before foreign keys and indexes); every other dialect ignores the
marker:
SELECT create_hypertable('readings', 'recorded_at', if_not_exists => TRUE, chunk_time_interval => INTERVAL '7 days');
Omitting chunkInterval drops the chunk_time_interval argument. The exposed plugin has no
hypertable equivalent and emits only an explanatory comment.
Additive-only schema-diff migrations
kuml generate --sql-migration computes the delta between two ERM model snapshots (--from
old, --to new) and writes a single Flyway-named migration file containing only safe, additive
DDL — CREATE TABLE, ALTER TABLE … ADD COLUMN, CREATE INDEX, CREATE VIEW, and
ALTER TABLE … ADD CONSTRAINT … CHECK:
kuml generate --sql-migration \
--from schema-v1.kuml.kts \
--to schema-v2.kuml.kts \
--version 2 \
--description add_orders \
--output migrations/
This writes migrations/V2add_orders.sql (Flyway V<version><description>.sql naming).
Both --from and --to must be ERM scripts; the mode is mutually exclusive with -i/--input,
and --options sql-dialect=<dialect> selects the dialect (default postgres).
Any destructive or ambiguous change — a dropped or renamed entity or column, a type or primary-key change — makes generation refuse, reporting the complete list of blockers at once instead of a fix-one-rerun-repeat loop:
Code generation error: kuml-gen-sql: refusing to generate an additive migration … — destructive
or ambiguous changes detected:
- entity 'users' was removed — dropping a table is destructive
Reverse: SQL→ERM
Existing SQL DDL can be reconstructed as an ERM model — useful for visualising a database that has no kUML model at all yet, as a crow’s-foot diagram:
kuml reverse schema.sql --format sql --dialect postgres
kuml reverse migrations/ --format sql --dialect postgres --output schema.kuml.kts
--format sql accepts either a single .sql file or a directory of .sql files
(all matched via */.sql); --dialect defaults to postgres
(PostgresErmReverseEngine, kuml-codegen-reverse-sql). The result is emitted as
ermModel(…) { … } DSL text, renderable in any of the four notations like any other
ERM script.