Skip to content

Add support for primary key and foreign key attributes #6

Closed

Description

@matejnovak29

The problem

Summary

Logical entities can currently only mark an attribute with
role: business_key. There is no way to declare:

  • which attribute(s) form the primary key of an entity, and
  • which attribute(s) are foreign keys, i.e. which entity/attribute they
    reference.

What you have in mind

Current state

logical/entities/customer.yaml:

id: 01M084QDTE7ZVZ2E8JDYM3FEY9
kind: logical_entity
name: customer
realises: 01M084QDTE7D6ER1SCBHTZEM33
attributes:
  - id: 01M085A3ENVRY7C8JV8FRCP9XZ
    name: customer_id
    domain: integer
    role: business_key
    nullable: true

customer_id is clearly the identifier of the entity, but there is no way to
say so, and nothing stops it from being nullable: true.

Proposal

  1. Extend the attribute role with primary_key and foreign_key
    (keeping business_key as is — a business/natural key is not the same
    thing as a surrogate primary key).

  2. For foreign keys, allow the target to be declared, e.g.:

    attributes:
      - id: ...
        name: customer_id
        domain: integer
        role: primary_key
        nullable: false
    
      - id: ...
        name: address_id
        domain: integer
        role: foreign_key
        references:
          entity: address          # or the entity id
          attribute: address_id    # optional, defaults to the target PK
        nullable: true

    Composite keys should be supported as well (multiple attributes marked
    primary_key, or an explicit primary_key: [col_a, col_b] block on the
    entity, plus an FK that maps several columns at once).

Expected effects

  • Validation: an attribute with role: primary_key should implicitly be
    nullable: false (or at least warn when it is not); an FK should fail
    validation when the referenced entity/attribute does not exist, and when the
    domains/types on both sides do not match.
  • Documentation: generated docs show PK/FK markers per attribute and list
    the relationships of each entity.
  • Diagram / visual canvas: relationships are drawn as lines between
    entities, with cardinality derived from FK nullability and uniqueness.
  • Physical/dbt output: PK and FK information is available for generating
    constraints and tests (unique, not_null, relationships).

Context

No response

Activity

  1. added a commit that references this issue on Sep 10, 2026
  2. dbose commented on Sep 10, 2026

    @dbose
    Owner

    Thanks for the detailed writeup - this is a real gap, and you're right that role: business_key alone can't express a primary key or a foreign key. But Modelith already models both, just not on the attribute's role. Keys are first-class objects, which is what lets them be composite and multi-column in a way a single role: field can't.

    Primary keys - a pk KeyGroup

    A primary key is a KeyGroup of type: pk whose ordered members are the PK attributes. Composite keys are just multiple members:

    # logical/key-groups/pk_customer.yaml
    id: 01J...PKCUST
    kind: key_group
    entity: 01M084QDTE7ZVZ2E8JDYM3FEY9   # customer
    name: pk_customer
    type: pk
    members:
      - 01M085A3ENVRY7C8JV8FRCP9XZ        # customer_id  (add more ULIDs for a composite PK)

    type can also be alternate, unique, or index for candidate/secondary keys. A pk KeyGroup is the authoritative primary key; the legacy per-attribute role: business_key still works but a pk KeyGroup wins.

    Foreign keys — a Relationship

    A foreign key is a Relationship whose from/to ends carry the attributes that map across. Multi-column FKs are just longer attributes lists (paired positionally):

    # logical/relationships/customer_address.yaml
    id: 01J...RELCA
    kind: relationship
    name: customer_address
    from:
      entity: 01M084QDTE7ZVZ2E8JDYM3FEY9   # customer
      attributes: [01J...ADDRFK]           # customer.address_id
    to:
      entity: 01J...ADDRESS                # address
      attributes: [01J...ADDRPK]           # address.address_id
    cardinality: many_to_one
    identifying: false                     # true = FK is part of the child's identity
    optionality: optional                  # optional = the FK column is nullable
    enforce:
      physical_constraint: true            # emit a DB-level FK where the platform supports it
      dbt_test: relationships              # and/or a dbt `relationships` test

    Your "expected effects" - where they already land

    • Validation - referential integrity checks that the referenced entity/attributes exist (MDL-E103), at most one PK per entity (MDL-E108), and PK members belong to their entity (MDL-E106). I've just added the two you specifically called out:
      • MDL-W115 - warns when a PK member is nullable: true.
      • MDL-E114 - errors when an FK's paired attributes have mismatched domains.
    • Documentation / dbt output - the dbt emitter turns all of this into schema.yml: primary_key, unique, and not_null constraints (where the adapter supports them), and dbt relationships tests for FKs. So the PK/FK information is already available for constraints and tests.
    • Diagram / canvas - relationships render as crow's-foot edges; cardinality drives the ends and identifying draws solid vs. dashed (IDEF1X).

    On the proposed role: primary_key / references: syntax

    I deliberately did not add this. It would be a second, weaker way to state a fact the KeyGroup/Relationship model already states - and it can't express composite keys or multi-column FKs without spilling into an entity-level block anyway, which is exactly the object model we already have. Two sources of truth for "what's the PK?" is how models drift, so I'd rather keep the one that's already richer.

    The two new validators (MDL-W115, MDL-E114) ship on branch 6-on-keys. If the docs on modelling keys weren't discoverable, that's fair feedback - point me at where you looked and I'll make sure the KeyGroup/Relationship path is easy to find.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions