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.

Blog Schema

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

entity(name, weak = false) { … }

Declares a table. weak = true marks a weak entity (rendered with a double-bordered box in Martin notation) — typically the child side of an identifying relationship.

attribute(name, type, primaryKey, nullable, unique, default, autoIncrement, foreignKey)

General column declaration. id() and foreignKey(…​) (below) are terser convenience wrappers around it.

index(vararg attributeNames, unique, name)

Index over already-declared columns of the enclosing entity, resolved by name.

check(expression, name)

A CHECK constraint with a dialect-neutral, raw SQL boolean expression.

view(name, query, references)

A first-class database view — raw SELECT body plus an optional list of referenced entity ids (checked for dangling references, not parsed).

category(supertype, subtypes, name, complete, discriminator)

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

a oneToMany b

source ONE, target ZERO_MANY

a manyToMany b

source ZERO_MANY, target ZERO_MANY

a oneToOne b

source ONE, target ZERO_ONE

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

Martin ("crow’s foot") is the default and most widely recognised notation — cardinality glyphs (, |, crow’s foot) sit directly on the connecting line.

Bachman

Bachman uses directed arrows rather than crow’s-foot glyphs — a style still common in older CASE tools and some database-design textbooks.

Chen

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

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):

Overview

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

SQL DDL via ErmSqlDdlGenerator / ErmSqlEmitter (kuml-gen-sql) — PostgreSQL first, then MySQL and SQLite. Composite primary keys, indexes, views, check constraints, referential actions, and M:N relationships emitted as real junction tables (not a TODO comment).

exposed

Kotlin Exposed Table objects via ErmExposedGenerator / ErmExposedEmitter (kuml-codegen-m2m-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 @Deprecatederm-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).

Places

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".

Sensors

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.