Files
michaelschiemer/docs/database/seeding.md
2025-11-24 21:28:25 +01:00

16 KiB

Database Seeding

Das Seed-System ermöglicht die Initialisierung der Datenbank mit Standard-Daten, getrennt von Schema-Migrationen. Es bietet eine saubere Trennung zwischen Schema-Änderungen (Migrations) und Initial-Daten (Seeds).

Übersicht

Migrations sind für Schema-Änderungen (CREATE TABLE, ALTER TABLE, etc.) Seeds sind für Initial-Daten (Standard-Content-Types, Default-Rollen, etc.)

Seeds sind idempotent - sie können mehrfach ausgeführt werden ohne Duplikate zu erstellen. Das System trackt ausgeführte Seeds in einer seeds Tabelle.

Architektur

Das Seed-System besteht aus folgenden Komponenten:

Core-Komponenten

  • Seeder Interface - Basis-Interface für alle Seeder-Klassen
  • SeedLoader - Lädt Seeder-Klassen über das Discovery-System
  • SeedRunner - Führt Seeder aus und verwaltet Idempotenz
  • SeedRepository - Verwaltet die seeds Tabelle für Tracking
  • SeedCommand - Console Command für manuelle Ausführung

Discovery-Integration

Das Seed-System nutzt das Framework Discovery-System zur automatischen Erkennung von Seeder-Klassen. Alle Klassen, die das Seeder Interface implementieren, werden automatisch gefunden und können ausgeführt werden.

Seeds Tabelle

Die seeds Tabelle wird durch die Migration CreateSeedsTable erstellt und trackt alle ausgeführten Seeders:

  • id (VARCHAR, PRIMARY KEY) - Eindeutige ID (SHA256 Hash des Seeder-Namens)
  • name (VARCHAR, UNIQUE) - Seeder-Name
  • description (TEXT) - Beschreibung des Seeders
  • executed_at (TIMESTAMP) - Zeitpunkt der Ausführung

Diese Tabelle verhindert, dass Seeders mehrfach ausgeführt werden.

Installation

1. Migration ausführen

Die seeds Tabelle wird automatisch erstellt, wenn die Migration CreateSeedsTable ausgeführt wird:

php console.php db:migrate

Die Migration ist Teil des Framework-Seed-Systems und wird automatisch erkannt.

2. Discovery-Cache aktualisieren

Nach dem Erstellen neuer Seeder-Klassen sollte der Discovery-Cache geleert werden:

php console.php cache:clear

Verwendung

Alle Seeders ausführen

Führt alle gefundenen Seeder aus, die noch nicht ausgeführt wurden:

php console.php db:seed

Ausgabe:

Running all seeders...

Found 1 seeder(s):
  - DefaultContentTypesSeeder: Seeds default CMS content types (page, post, landing_page)

[info] Running seeder 'DefaultContentTypesSeeder'...
[info] Seeder 'DefaultContentTypesSeeder' completed successfully

✅ All seeders completed.

Spezifischen Seeder ausführen

Führt nur einen bestimmten Seeder aus:

php console.php db:seed --class=DefaultContentTypesSeeder

Ausgabe:

Running seeder: DefaultContentTypesSeeder
[info] Running seeder 'DefaultContentTypesSeeder'...
[info] Seeder 'DefaultContentTypesSeeder' completed successfully
✅ Seeder 'DefaultContentTypesSeeder' completed.

Alle Seeds neu ausführen (--fresh)

Löscht die seeds Tabelle und führt alle Seeders neu aus:

php console.php db:seed --fresh

Warnung: Die --fresh Option sollte nur in Development-Umgebungen verwendet werden.

Ausgabe:

⚠️  Clearing seeds table (--fresh option)...
✅ Seeds table cleared.

Running all seeders...
...

Seeder erstellen

1. Seeder-Klasse erstellen

Erstelle eine Klasse, die das Seeder Interface implementiert:

<?php

declare(strict_types=1);

namespace App\Domain\YourDomain\Seeds;

use App\Framework\Database\Seed\Seeder;
use App\Domain\YourDomain\Services\YourService;

final readonly class YourSeeder implements Seeder
{
    public function __construct(
        private YourService $yourService
    ) {}

    public function seed(): void
    {
        // Your seeding logic here
        // Should be idempotent - check if data exists before creating
    }

    public function getName(): string
    {
        return 'YourSeeder';
    }

    public function getDescription(): string
    {
        return 'Description of what this seeder does';
    }
}

2. Idempotenz sicherstellen

Seeds sollten idempotent sein - sie sollten prüfen, ob Daten bereits existieren, bevor sie erstellt werden:

Beispiel 1: Mit Exception-Handling

public function seed(): void
{
    try {
        $this->service->findBySlug('example');
        // Skip if already exists
    } catch (\RuntimeException $e) {
        // Create if not exists
        $this->service->create(
            name: 'Example',
            slug: 'example',
            description: 'Example description'
        );
    }
}

Beispiel 2: Mit expliziter Existenz-Prüfung

public function seed(): void
{
    if (!$this->repository->exists(ExampleId::fromString('example'))) {
        $this->service->create(...);
    }
}

Beispiel 3: Mit Config-Datei

public function seed(): void
{
    $configPath = $this->pathProvider->getBasePath()
        ->join('config', 'your-domain', 'default-data.php');
    
    $defaultData = require $configPath->toString();

    foreach ($defaultData as $slug => $data) {
        try {
            $this->service->findBySlug($slug);
            // Skip if already exists
        } catch (\RuntimeException $e) {
            $this->service->create(
                name: $data['name'],
                slug: $slug,
                description: $data['description'] ?? null
            );
        }
    }
}

3. Verzeichnis-Struktur

Seeder sollten im Seeds Verzeichnis der jeweiligen Domain liegen:

src/Domain/
├── Cms/
│   └── Seeds/
│       └── DefaultContentTypesSeeder.php
└── User/
    └── Seeds/
        └── DefaultRolesSeeder.php

Das Discovery-System findet automatisch alle Seeder-Klassen, die das Seeder Interface implementieren.

4. Dependency Injection

Seeder können alle Framework-Services über Dependency Injection verwenden:

final readonly class YourSeeder implements Seeder
{
    public function __construct(
        private YourService $yourService,
        private PathProvider $pathProvider,
        private Clock $clock,
        private Logger $logger
    ) {}
    
    // ...
}

Der DI Container stellt automatisch alle benötigten Dependencies bereit.

Standard-Content-Types

Das CMS-System stellt Standard-Content-Types über den DefaultContentTypesSeeder bereit:

  • page - Standard-Seiten (z.B. "Über uns", "Impressum", "Kontakt")
  • post - Blog-Artikel/News
  • landing_page - Marketing-Landing-Pages

Diese werden automatisch erstellt, wenn der Seeder ausgeführt wird. Die Definitionen können in config/cms/default-content-types.php angepasst werden.

Config-Datei anpassen

Die Standard-Content-Types werden in config/cms/default-content-types.php definiert:

<?php

declare(strict_types=1);

return [
    'page' => [
        'name' => 'Page',
        'description' => 'Standard pages for general content',
        'isSystem' => true,
    ],
    'post' => [
        'name' => 'Post',
        'description' => 'Blog posts and news articles',
        'isSystem' => true,
    ],
    'landing_page' => [
        'name' => 'Landing Page',
        'description' => 'Marketing landing pages for campaigns',
        'isSystem' => true,
    ],
];

Du kannst diese Datei anpassen, um weitere Content-Types hinzuzufügen oder bestehende zu ändern. Nach Änderungen führe db:seed --fresh aus, um die Seeds neu auszuführen.

Seeds Tabelle

Das Seed-System verwendet eine seeds Tabelle, um zu tracken, welche Seeders bereits ausgeführt wurden:

Schema

CREATE TABLE seeds (
    id VARCHAR(255) PRIMARY KEY,
    name VARCHAR(255) UNIQUE NOT NULL,
    description TEXT,
    executed_at TIMESTAMP NOT NULL
);

CREATE INDEX idx_seeds_name ON seeds(name);

Spalten

  • id (VARCHAR, PRIMARY KEY) - Eindeutige ID (SHA256 Hash des Seeder-Namens)
  • name (VARCHAR, UNIQUE) - Seeder-Name (muss eindeutig sein)
  • description (TEXT) - Beschreibung des Seeders
  • executed_at (TIMESTAMP) - Zeitpunkt der Ausführung

Verwendung

Die Tabelle wird automatisch erstellt, wenn die Migration CreateSeedsTable ausgeführt wird. Der SeedRepository verwaltet diese Tabelle automatisch:

  • hasRun(string $name): bool - Prüft, ob ein Seeder bereits ausgeführt wurde
  • markAsRun(string $name, string $description): void - Markiert einen Seeder als ausgeführt
  • clearAll(): void - Löscht alle Einträge (für --fresh Option)

Best Practices

1. Idempotenz

Seeds sollten immer idempotent sein - prüfe auf Existenz vor dem Erstellen:

public function seed(): void
{
    // ✅ GUT: Prüft Existenz vor Erstellung
    try {
        $this->service->findBySlug('example');
        return; // Skip if exists
    } catch (\RuntimeException $e) {
        $this->service->create(...);
    }
    
    // ❌ SCHLECHT: Erstellt ohne Prüfung
    $this->service->create(...); // Kann Duplikate erzeugen
}

2. Config-Dateien

Verwende Config-Dateien für anpassbare Seed-Daten:

// config/your-domain/default-data.php
return [
    'example' => [
        'name' => 'Example',
        'description' => 'Example description',
        'isSystem' => true,
    ],
];

Vorteile:

  • Einfache Anpassung ohne Code-Änderung
  • Versionierbar in Git
  • Kann in verschiedenen Umgebungen unterschiedlich sein

3. System-Daten

Markiere System-Daten mit isSystem: true um sie vor Löschung zu schützen:

$this->service->create(
    name: 'System Role',
    slug: 'admin',
    isSystem: true  // Kann nicht gelöscht werden
);

4. Fehlerbehandlung

Seeds sollten aussagekräftige Fehlermeldungen werfen:

public function seed(): void
{
    try {
        $configPath = $this->pathProvider->getBasePath()
            ->join('config', 'domain', 'data.php');
        
        if (!file_exists($configPath->toString())) {
            throw new \RuntimeException(
                "Config file not found: {$configPath->toString()}"
            );
        }
        
        // ... seeding logic
    } catch (\Throwable $e) {
        throw new \RuntimeException(
            "Failed to seed data: {$e->getMessage()}",
            0,
            $e
        );
    }
}

5. Dokumentation

Dokumentiere, was jeder Seeder macht:

/**
 * Seeds default user roles for the application
 * 
 * Creates the following roles:
 * - admin: Full system access
 * - editor: Content editing access
 * - viewer: Read-only access
 */
final readonly class DefaultRolesSeeder implements Seeder
{
    // ...
}

6. Abhängigkeiten zwischen Seeders

Wenn Seeders voneinander abhängen, stelle sicher, dass sie in der richtigen Reihenfolge ausgeführt werden:

// Seeder 1: Muss zuerst ausgeführt werden
final readonly class DefaultRolesSeeder implements Seeder
{
    public function getName(): string
    {
        return 'DefaultRolesSeeder'; // Wird alphabetisch zuerst ausgeführt
    }
}

// Seeder 2: Kann danach ausgeführt werden
final readonly class DefaultUsersSeeder implements Seeder
{
    public function getName(): string
    {
        return 'DefaultUsersSeeder'; // Wird nach DefaultRolesSeeder ausgeführt
    }
}

Hinweis: Seeders werden alphabetisch nach Namen sortiert. Verwende Präfixe für die Reihenfolge, falls nötig (z.B. 01_DefaultRolesSeeder, 02_DefaultUsersSeeder).

Migration vs. Seed

Migration Seed
Schema-Änderungen Initial-Daten
Forward-only (optional rollback) Idempotent (kein rollback)
Versioniert Getrackt in seeds Tabelle
Automatisch beim db:migrate Manuell mit db:seed

Beispiele

Beispiel: Default Roles Seeder

<?php

declare(strict_types=1);

namespace App\Domain\User\Seeds;

use App\Domain\User\Services\RoleService;
use App\Framework\Database\Seed\Seeder;

final readonly class DefaultRolesSeeder implements Seeder
{
    public function __construct(
        private RoleService $roleService
    ) {}

    public function seed(): void
    {
        $roles = [
            ['name' => 'Admin', 'slug' => 'admin', 'permissions' => ['*']],
            ['name' => 'Editor', 'slug' => 'editor', 'permissions' => ['content.edit']],
            ['name' => 'Viewer', 'slug' => 'viewer', 'permissions' => ['content.view']],
        ];

        foreach ($roles as $roleData) {
            try {
                $this->roleService->findBySlug($roleData['slug']);
                // Skip if already exists
            } catch (\RuntimeException $e) {
                $this->roleService->create(
                    name: $roleData['name'],
                    slug: $roleData['slug'],
                    permissions: $roleData['permissions']
                );
            }
        }
    }

    public function getName(): string
    {
        return 'DefaultRolesSeeder';
    }

    public function getDescription(): string
    {
        return 'Seeds default user roles (admin, editor, viewer)';
    }
}

Beispiel: Seeder mit Config-Datei

<?php

declare(strict_types=1);

namespace App\Domain\Settings\Seeds;

use App\Domain\Settings\Services\SettingService;
use App\Framework\Core\PathProvider;
use App\Framework\Database\Seed\Seeder;

final readonly class DefaultSettingsSeeder implements Seeder
{
    public function __construct(
        private SettingService $settingService,
        private PathProvider $pathProvider
    ) {}

    public function seed(): void
    {
        $configPath = $this->pathProvider->getBasePath()
            ->join('config', 'settings', 'defaults.php');
        
        if (!file_exists($configPath->toString())) {
            throw new \RuntimeException(
                "Config file not found: {$configPath->toString()}"
            );
        }

        $defaultSettings = require $configPath->toString();

        foreach ($defaultSettings as $key => $value) {
            if (!$this->settingService->exists($key)) {
                $this->settingService->create($key, $value);
            }
        }
    }

    public function getName(): string
    {
        return 'DefaultSettingsSeeder';
    }

    public function getDescription(): string
    {
        return 'Seeds default application settings';
    }
}

Troubleshooting

Seeder wird nicht gefunden

Problem: Der Seeder wird nicht vom Discovery-System gefunden.

Lösung:

  1. Stelle sicher, dass die Klasse das Seeder Interface implementiert
  2. Stelle sicher, dass die Klasse im Seeds Verzeichnis liegt
  3. Leere den Discovery-Cache: php console.php cache:clear
  4. Prüfe, ob das Seeder Interface in den targetInterfaces der Discovery-Konfiguration ist

Debug:

# Prüfe, ob Seeder gefunden werden
php console.php db:seed
# Sollte "Found X seeder(s):" anzeigen

Seeder wird mehrfach ausgeführt

Problem: Der Seeder wird trotz Tracking mehrfach ausgeführt.

Lösung:

  1. Prüfe, ob die seeds Tabelle existiert: php console.php db:status | grep seeds
  2. Prüfe, ob der Seeder-Name korrekt ist (muss eindeutig sein)
  3. Prüfe die seeds Tabelle direkt:
    SELECT * FROM seeds WHERE name = 'YourSeeder';
    

SQL Syntax Error (ON DUPLICATE KEY UPDATE)

Problem: SQLSTATE[42601]: Syntax error: syntax error at or near "DUPLICATE"

Ursache: Die Datenbank ist PostgreSQL, aber der Code verwendet MySQL-Syntax.

Lösung: Verwende PostgreSQL-Syntax (ON CONFLICT) statt MySQL-Syntax (ON DUPLICATE KEY UPDATE):

// ❌ MySQL-Syntax (funktioniert nicht mit PostgreSQL)
INSERT INTO table (...) VALUES (...)
ON DUPLICATE KEY UPDATE ...

// ✅ PostgreSQL-Syntax
INSERT INTO table (...) VALUES (...)
ON CONFLICT (id) DO UPDATE SET ...

Seeder schlägt fehl

Problem: Der Seeder wirft eine Exception.

Lösung:

  1. Prüfe die Fehlermeldung in der Console-Ausgabe
  2. Stelle sicher, dass alle Dependencies verfügbar sind
  3. Prüfe, ob Config-Dateien existieren und korrekt formatiert sind
  4. Verwende --fresh um alle Seeds neu auszuführen (nur Development)

Migration für seeds Tabelle fehlt

Problem: SQLSTATE[42P01]: Undefined table: relation "seeds" does not exist

Lösung:

  1. Führe die Migration aus: php console.php db:migrate
  2. Prüfe, ob die Migration CreateSeedsTable existiert
  3. Prüfe den Migrations-Status: php console.php db:status