Skip to content

Content Relations — Typed M:N Self-Referential Links ​

Link articles (or any resource) to each other using a join table with a relation_type column. Support asymmetric types (sequel ↔ prequel) with automatic inverse insertion, and symmetric types (related, reference) with the same inverse logic.

Reference implementation: FT173 relatedlog in hideyukiMORI/NENE2-examples


When to Use This Pattern ​

Use this when…Consider alternatives when…
Resources link to each other with typed edgesYou only need untyped "related" links
Asymmetric edges are needed (A is a sequel of B)A simple tagging system is sufficient
Bidirectional queries must stay fastGraph traversal across many hops is required
Relation type drives UI behavior ("See sequels")—

Schema ​

sql
CREATE TABLE articles (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT    NOT NULL,
    body       TEXT    NOT NULL,
    created_at TEXT    NOT NULL
);

CREATE TABLE article_relations (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    article_id    INTEGER NOT NULL,
    related_id    INTEGER NOT NULL,
    relation_type TEXT    NOT NULL,
    -- 'related' | 'sequel' | 'prequel' | 'reference'
    created_at    TEXT    NOT NULL,
    UNIQUE (article_id, related_id, relation_type),
    FOREIGN KEY (article_id) REFERENCES articles(id),
    FOREIGN KEY (related_id) REFERENCES articles(id),
    CHECK (article_id != related_id)      -- self-relation prevented at DB level
);

Design Notes ​

  • The UNIQUE (article_id, related_id, relation_type) constraint prevents duplicate edges of the same type. The same pair can have multiple types (e.g., A → B as both related and reference).
  • CHECK (article_id != related_id) prevents self-loops at the database level.
  • Both directions are stored: adding A → B (sequel) also inserts B → A (prequel). This makes per-article queries trivial (WHERE article_id = ?) with no joins.

Relation Types ​

php
enum RelationType: string
{
    case Related   = 'related';    // symmetric: A related B ↔ B related A
    case Sequel    = 'sequel';     // asymmetric: A sequel→B ↔ B prequel→A
    case Prequel   = 'prequel';    // asymmetric: inverse of sequel
    case Reference = 'reference';  // symmetric: bidirectional citation

    public function inverse(): self
    {
        return match ($this) {
            self::Sequel  => self::Prequel,
            self::Prequel => self::Sequel,
            default       => $this,  // related, reference are self-inverse
        };
    }
}

Core Operation: Add Relation with Automatic Inverse ​

php
public function addRelation(int $articleId, int $relatedId, RelationType $type, string $now): ArticleRelation
{
    // 1. Validate both articles exist
    // 2. Check for duplicate (UNIQUE constraint would also catch this)
    $existing = $this->db->fetchOne(
        'SELECT id FROM article_relations WHERE article_id = ? AND related_id = ? AND relation_type = ?',
        [$articleId, $relatedId, $type->value],
    );
    if ($existing !== null) {
        throw new RelationAlreadyExistsException($articleId, $relatedId, $type);
    }

    // 3. Insert forward relation
    $id = $this->db->insert(
        'INSERT INTO article_relations (article_id, related_id, relation_type, created_at) VALUES (?, ?, ?, ?)',
        [$articleId, $relatedId, $type->value, $now],
    );

    // 4. Insert inverse (if not already there)
    $inverse = $type->inverse();
    $inverseExists = $this->db->fetchOne(
        'SELECT id FROM article_relations WHERE article_id = ? AND related_id = ? AND relation_type = ?',
        [$relatedId, $articleId, $inverse->value],
    );
    if ($inverseExists === null) {
        $this->db->insert(
            'INSERT INTO article_relations (article_id, related_id, relation_type, created_at) VALUES (?, ?, ?, ?)',
            [$relatedId, $articleId, $inverse->value, $now],
        );
    }

    return new ArticleRelation($id, $articleId, $relatedId, $type, $now);
}

Remove Relation (cascade inverse) ​

php
public function removeRelation(int $articleId, int $relatedId, RelationType $type): bool
{
    $deleted = $this->db->execute(
        'DELETE FROM article_relations WHERE article_id = ? AND related_id = ? AND relation_type = ?',
        [$articleId, $relatedId, $type->value],
    );
    // Remove inverse
    $inverse = $type->inverse();
    $this->db->execute(
        'DELETE FROM article_relations WHERE article_id = ? AND related_id = ? AND relation_type = ?',
        [$relatedId, $articleId, $inverse->value],
    );
    return $deleted > 0;
}

Endpoints ​

MethodPathDescription
POST/articlesCreate an article
GET/articles/{id}Get article with embedded related stubs
POST/articles/{id}/relationsAdd a relation (+ auto-inserts inverse)
GET/articles/{id}/relationsList relations (?type=sequel to filter)
DELETE/articles/{id}/relations/{relatedId}Remove relation (?type=sequel required)

Response Shapes ​

GET /articles/{id} — with embedded relations ​

json
{
  "data": { "id": 1, "title": "Part 1", ... },
  "relations": [
    {
      "relation": { "id": 1, "article_id": 1, "related_id": 2, "relation_type": "sequel", ... },
      "related":  { "id": 2, "title": "Part 2", ... }
    }
  ]
}

POST /articles/{id}/relations — request ​

json
{
  "related_id": 2,
  "relation_type": "sequel"
}

DELETE /articles/{id}/relations/ ​

DELETE /articles/1/relations/2?type=sequel

The type query param is required — a pair can have multiple relation types simultaneously, so the type disambiguates which edge to remove.


Domain Layer Structure ​

src/Article/
├── Article.php
├── ArticleRelation.php
├── ArticleRepository.php       # addRelation / removeRelation / listRelations / findWithRelations
├── RelationType.php            # enum with inverse()
├── ArticleNotFoundException.php
└── RelationAlreadyExistsException.php

Edge Cases ​

ScenarioBehavior
Self-relation (article_id == related_id)422 — checked in handler before DB
Duplicate type between same pair409 Conflict
Same pair with different type201 — valid, stored as separate rows
Remove non-existent relation404
Remove without type param422
Missing articles404 for each invalid ID

See Also ​

Released under the MIT License.