Skip to content

Data

Entity-Hydration, Change-Tracking und Identity-Generierung, die Brücke zwischen Datenbank und Domain.

Einführung

Zwischen einer Datenbankzeile und einem PHP-Objekt liegt eine Menge Arbeit: Spaltennamen in Property-Namen übersetzen, Strings zu int, bool oder DateTimeImmutable casten, Änderungen erkennen, UUIDs generieren. Die meisten Lösungen dafür heißen Doctrine, und bringen ein ganzes ORM mit.

jardissupport/data löst genau diese Probleme, ohne ein ORM zu sein. Drei fokussierte Orchestratoren, die sich unabhängig voneinander einsetzen lassen:

  • Hydration — Befüllt PHP-Objekte aus Datenbank-Arrays, inklusive verschachtelter Aggregate mit beliebiger Tiefe. Automatisches Type-Casting, Setter-Respektierung, und ein Snapshot-Mechanismus für Change-Tracking
  • Identity — UUID v4, v5, v7 und NanoID ohne externe Abhängigkeiten. UUID v7 mit monotonem Counter für kollisionssicheres Batch-Insert
  • FieldMapper — Bidirektionale Key-Übersetzung zwischen Domain (customerName) und Datenbank (customer_name)

Installation

bash
composer require jardissupport/data

GitHub: jardisSupport/data

Keine optionalen Extensions nötig, das Package arbeitet mit purem PHP.

Hydration

Entity befüllen

php
use JardisSupport\Data\Hydration;

$hydration = new Hydration();

$user = new User();
$hydration->hydrate($user, [
    'id'         => 1,
    'first_name' => 'John',
    'email'      => 'john@example.com',
    'age'        => '30',         // → int 30
    'active'     => '1',          // → bool true
    'created_at' => '2025-01-15 10:00:00',  // → DateTimeImmutable
]);

Automatisches Type-Casting basierend auf dem Property-Typ:

Property-TypDB-WertPHP-Wert
int'42'42
float'9.99'9.99
bool'1' / '0'true / false
DateTime'2025-01-15 10:00:00'DateTime-Objekt
DateTimeImmutable'2025-01-15'DateTimeImmutable-Objekt
BackedEnum'ELECTRICITY'GatewayType::Electricity

Spaltenname → Property-Name wird automatisch konvertiert: first_namefirstName, created_atcreatedAt.

Setter werden respektiert: Existiert eine setFirstName()-Methode, wird sie aufgerufen statt das Property direkt zu setzen.

hydrate() vs. apply()

Zwei Methoden, ein entscheidender Unterschied:

php
// hydrate(): Setzt Properties UND aktualisiert den Snapshot
$hydration->hydrate($entity, $dbRow);
$hydration->getChanges($entity);  // [] — keine Änderungen

// apply(): Setzt Properties, lässt den Snapshot unverändert
$hydration->apply($entity, ['name' => 'Jane']);
$hydration->getChanges($entity);  // ['name' => 'Jane'] — als Änderung erkannt

hydrate() für Daten aus der Datenbank. apply() für programmatische Änderungen, die als Diff erkannt werden sollen.

Change-Tracking

Jede Hydration erstellt einen Snapshot: ein Array der ursprünglichen Werte. Änderungen werden durch Vergleich mit dem Snapshot erkannt:

php
$hydration->hydrate($user, ['id' => 1, 'name' => 'John', 'email' => 'john@example.com']);

$user->setName('Jane');
$user->setEmail('jane@example.com');

$hydration->getChanges($user);
// ['name' => 'Jane', 'email' => 'jane@example.com']

$hydration->getChangedFields($user);
// ['name', 'email']

$hydration->getSnapshot($user);
// ['id' => 1, 'name' => 'John', 'email' => 'john@example.com']

Snapshot-Werte sind immer Skalare: DateTime wird als 'Y-m-d H:i:s' gespeichert, BackedEnum als $enum->value. Dadurch gibt es keine False-Positives durch Objekt-Identitätsvergleiche.

Snapshot-Property

Entities benötigen ein private array $__snapshot = [] Property. Der Snapshot wird per Reflection geschrieben. Optional: eine getSnapshot(): array-Methode als Performance-Fast-Path.

Aggregate-Hydration

Verschachtelte Objektgraphen (ein Root-Entity mit ONE- und MANY-Relationen) werden rekursiv hydriert:

php
$order = new Order();
$hydration->hydrateAggregate($order, [
    'id'          => 1,
    'customer_id' => 42,
    'total'       => '299.99',
    // ONE-Relation: assoziatives Array → neues Objekt
    'shipping_address' => [
        'id'      => 10,
        'street'  => 'Hauptstraße 1',
        'city'    => 'Berlin',
        'country' => [
            'id'   => 'DE',
            'name' => 'Germany',
        ],
    ],
    // MANY-Relation: Array von assoziativen Arrays → Collection
    'order_lines' => [
        ['id' => 100, 'product_id' => 'P-001', 'quantity' => 2, 'price' => '49.99'],
        ['id' => 101, 'product_id' => 'P-002', 'quantity' => 1, 'price' => '199.99'],
    ],
]);

$order->getShippingAddress()->getCountry()->getName(); // 'Germany'
$order->getOrderLines()[0]->getQuantity();             // int 2

Die Erkennung von ONE vs. MANY erfolgt automatisch anhand der Datenstruktur:

  • Assoziatives Array + Property-Typ ist eine Klasse → ONE-Relation
  • Indiziertes Array von assoziativen Arrays → MANY-Relation

Collection-Elemente werden über eine add{SingularName}()-Methode oder @var SomeClass[] Docblock typisiert.

Jede Ebene bekommt ihren eigenen Snapshot, nur die DB-Spalten, keine Relations-Keys.

Entity zu Array

php
// Flach — nur DB-Spalten
$hydration->toArray($user);
// ['id' => 1, 'name' => 'Jane', 'email' => 'jane@example.com', 'created_at' => '2025-01-15 10:00:00']

// Verschachtelt — ganzer Aggregate-Graph
$hydration->aggregateToArray($order);
// ['id' => 1, 'customer_id' => 42, 'shipping_address' => ['id' => 10, ...], 'order_lines' => [...]]

Clone und Diff

php
// Flacher Clone — nur DB-Spalten-Properties
$clone = $hydration->clone($user);

// Tiefer Clone — gesamter Aggregate-Graph, DateTime-Objekte geklont
$clonedOrder = $hydration->cloneAggregate($order);

// Diff — vergleicht zwei Entities derselben Klasse
$user1->setName('John');
$user2->setName('Jane');
$hydration->diff($user1, $user2);  // ['name' => 'Jane']

Batch-Hydration

php
$rows = $pdo->query('SELECT * FROM users')->fetchAll(PDO::FETCH_ASSOC);
$users = $hydration->loadMultiple(new User(), $rows);
// Array von hydrierten User-Objekten mit individuellen Snapshots

Identity

Vier ID-Strategien ohne externe Abhängigkeiten:

php
use JardisSupport\Data\Identity;

$identity = new Identity();

// UUID v4 — Random
$identity->generateUuid4();
// 'a3f8b2c1-4d5e-4f6a-8b7c-9d0e1f2a3b4c'

// UUID v7 — Zeitbasiert + monotoner Counter
$identity->generateUuid7();
// '01912345-6789-7abc-8def-0123456789ab'

// UUID v5 — Deterministisch (SHA-1)
$identity->generateUuid5($namespaceUuid, 'customer:12345');
// Gleicher Input → immer gleiche UUID

// NanoID — Kompakt, URL-safe
$identity->generateNanoId();             // 21 Zeichen, ~126 Bit Entropie
$identity->generateNanoId(length: 10);   // Kürzere ID

UUID v7 — Kollisionssicher im Batch

UUID v7 ist zeitbasiert und sortierbar. Der monotone Counter garantiert eindeutige, aufsteigende IDs auch bei tausenden Inserts pro Millisekunde:

  • Bytes 0–5: 48-Bit Unix-Millisekunden-Timestamp
  • Bytes 6–7: 12-Bit monotoner Counter (Startwert pro Millisekunde zufällig im Bereich 0–31)
  • Bytes 8–15: 62-Bit Random

Bei Counter-Overflow (> 4095 pro ms) wartet der Generator auf die nächste Millisekunde.

UUID v5 — Deterministische IDs

Gleicher Namespace + gleicher Name → immer die gleiche UUID. Ideal für stabile IDs aus Business-Keys:

php
$namespace = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; // DNS-Namespace

$identity->generateUuid5($namespace, 'customer:12345');
// Immer: '...' (deterministisch)

NanoID — Kompakt und URL-safe

Verwendet Bitmask-Rejection-Sampling auf random_bytes für gleichmäßige Verteilung. Standard-Alphabet: 64 Zeichen (_-0-9a-zA-Z), Standardlänge 21 (~126 Bit Entropie). Beides pro Aufruf konfigurierbar.

FieldMapper

Bidirektionale Key-Übersetzung zwischen Domain-Modell und Datenbank:

php
use JardisSupport\Data\FieldMapper;

$mapper = new FieldMapper();

$map = [
    'customerName' => 'name',
    'orderNumber'  => 'order_number',
    'postalCode'   => 'postal_code',
];

// Domain → DB
$mapper->toColumns(['customerName' => 'John', 'postalCode' => '10115'], $map);
// ['name' => 'John', 'postal_code' => '10115']

// DB → Domain (rekursiv für verschachtelte Arrays)
$mapper->fromColumns(['name' => 'John', 'postal_code' => '10115'], $map);
// ['customerName' => 'John', 'postalCode' => '10115']

Aggregate-Mapping

Für verschachtelte Strukturen mit pro-Entity-Mappings:

php
$mapProvider = fn(string $entity) => match ($entity) {
    'order'    => ['orderNumber' => 'order_number', 'status' => 'status'],
    'customer' => ['customerName' => 'name', 'email' => 'email'],
    'item'     => ['productId' => 'product_identifier', 'quantity' => 'quantity'],
};

$result = $mapper->fromAggregate($aggregateArray, $mapProvider, 'order');

Verhalten bei ungemappten Keys

toColumns() und fromColumns() reichen ungemappte Keys unverändert durch. fromAggregate() filtert ungemappte Keys heraus: nur explizit gemappte Felder verlassen die Repository-Schicht.

PHP-Attribute

Das Package definiert Attribute für Metadaten, die primär vom Builder-Tooling genutzt werden. Die Hydration selbst benötigt keine Attribute. Sie arbeitet wertbasiert.

AttributZielParameter
#[Table]Klassename, schema
#[Aggregate]Klassename, root
#[Column]Propertyname, type, length, precision, scale, nullable, default, unique
#[PrimaryKey]PropertyautoIncrement
#[ForeignKey]PropertyreferencedTable, referencedColumn, onUpdate, onDelete
#[Relation]Propertytype ('one'/'many'), target

Architektur

Drei unabhängige Orchestratoren, jeder mit eigenen Handlern im Closure-Orchestrator-Pattern:

Hydration                              ← Orchestrator
├── HydrateEntity                      ← Flat Hydration + Snapshot
├── HydrateAggregate                   ← Rekursive Graph-Hydration
├── DetectChanges                      ← Snapshot-Diff
├── SetSnapshot / GetSnapshot          ← Snapshot-Zugriff (Reflection)
├── SetPropertyValue / GetPropertyValue ← Setter/Getter-Auflösung
├── TypeCaster                         ← DB → PHP Type-Casting
├── EntityToArray / AggregateToArray   ← Entity → Array
├── CloneEntity / CloneAggregate       ← Flat/Deep Clone
├── DiffEntities                       ← Entity-Vergleich
└── LoadMultiple                       ← Batch-Hydration

Identity                               ← Orchestrator
├── GenerateUuid4                      ← Random UUID
├── GenerateUuid5                      ← Deterministisch (SHA-1)
├── GenerateUuid7                      ← Zeitbasiert + Counter
└── GenerateNanoId                     ← Kompakt, URL-safe

FieldMapper                            ← Orchestrator
├── ColumnNameToPropertyName           ← snake_case → camelCase
└── PropertyNameToColumnName           ← camelCase → snake_case

Verzeichnisstruktur

src/
├── Hydration.php                ← Orchestrator
├── Identity.php                 ← Orchestrator
├── FieldMapper.php              ← Orchestrator
├── Attribute/
│   ├── Aggregate.php
│   ├── Column.php
│   ├── ForeignKey.php
│   ├── PrimaryKey.php
│   ├── Relation.php
│   └── Table.php
└── Handler/
    ├── HydrateEntity.php
    ├── HydrateAggregate.php
    ├── DetectChanges.php
    ├── SetSnapshot.php
    ├── GetSnapshot.php
    ├── ToSnapshotValue.php
    ├── SetPropertyValue.php
    ├── GetPropertyValue.php
    ├── ColumnNameToPropertyName.php
    ├── PropertyNameToColumnName.php
    ├── TypeCaster.php
    ├── EntityToArray.php
    ├── AggregateToArray.php
    ├── CloneEntity.php
    ├── CloneAggregate.php
    ├── DiffEntities.php
    ├── LoadMultiple.php
    ├── GenerateUuid4.php
    ├── GenerateUuid5.php
    ├── GenerateUuid7.php
    └── GenerateNanoId.php

API-Referenz

Hydration

MethodeSignaturBeschreibung
hydratehydrate(object $entity, array $data): objectBefüllen + Snapshot aktualisieren
applyapply(object $entity, array $data): objectBefüllen ohne Snapshot-Update
hydrateAggregatehydrateAggregate(object $aggregate, array $data): objectRekursive Graph-Hydration
getChangesgetChanges(object $entity): arrayGeänderte Spalten + neue Werte
getChangedFieldsgetChangedFields(object $entity): arrayNur die geänderten Spaltennamen
getSnapshotgetSnapshot(object $entity): arrayAktueller Snapshot
cloneclone(object $entity): objectFlacher Clone
cloneAggregatecloneAggregate(object $entity): objectTiefer Clone
diffdiff(object $a, object $b): arrayUnterschiede zwischen zwei Entities
toArraytoArray(object $entity): arrayEntity → flaches Array
aggregateToArrayaggregateToArray(object $entity): arrayAggregate → verschachteltes Array
loadMultipleloadMultiple(object $template, array $rows): arrayBatch-Hydration

Identity

MethodeSignaturBeschreibung
generateUuid4generateUuid4(): stringRandom UUID v4
generateUuid5generateUuid5(string $namespace, string $name): stringDeterministisches UUID v5
generateUuid7generateUuid7(): stringZeitbasiertes UUID v7
generateNanoIdgenerateNanoId(int $length = 21, string $alphabet = '...'): stringKompakte Random-ID

FieldMapper

MethodeSignaturBeschreibung
toColumnstoColumns(array $data, array $map): arrayDomain → DB Keys
fromColumnsfromColumns(array $data, array $map): arrayDB → Domain Keys (rekursiv)
fromAggregatefromAggregate(array $data, callable $mapProvider, string $entityName): arrayAggregate-Mapping

Vollständiges Beispiel

Ein Repository-Use-Case mit Hydration, Change-Tracking und Identity:

php
use JardisSupport\Data\Hydration;
use JardisSupport\Data\Identity;
use JardisSupport\Data\FieldMapper;

$hydration = new Hydration();
$identity  = new Identity();
$mapper    = new FieldMapper();

// Neues Entity mit UUID v7 erstellen
$order = new Order();
$order->setId($identity->generateUuid7());

// Aus DB laden und hydrieren (inkl. Relationen)
$hydration->hydrateAggregate($order, [
    'id'          => $order->getId(),
    'customer_id' => 42,
    'total'       => '299.99',
    'status'      => 'pending',
    'order_lines' => [
        ['id' => 100, 'product_id' => 'P-001', 'quantity' => 2, 'price' => '49.99'],
        ['id' => 101, 'product_id' => 'P-002', 'quantity' => 1, 'price' => '199.99'],
    ],
]);

// Änderungen machen
$order->setTotal(349.99);
$order->setStatus('confirmed');

// Change-Tracking — nur geänderte Felder für das UPDATE
$changes = $hydration->getChanges($order);
// ['total' => 349.99, 'status' => 'confirmed']

// Für das UPDATE: Domain-Keys → DB-Spalten
$map = ['total' => 'total_amount', 'status' => 'order_status'];
$dbValues = $mapper->toColumns($changes, $map);
// ['total_amount' => 349.99, 'order_status' => 'confirmed']

// Backup für Vergleich
$backup = $hydration->clone($order);
$order->setTotal(399.99);
$hydration->diff($backup, $order);  // ['total' => 399.99]

// Batch-Hydration
$rows = $pdo->query('SELECT * FROM order_lines WHERE order_id = ?')->fetchAll();
$lines = $hydration->loadMultiple(new OrderLine(), $rows);