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
composer require jardissupport/dataGitHub: jardisSupport/data
Keine optionalen Extensions nötig, das Package arbeitet mit purem PHP.
Hydration
Entity befüllen
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-Typ | DB-Wert | PHP-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_name → firstName, created_at → createdAt.
Setter werden respektiert: Existiert eine setFirstName()-Methode, wird sie aufgerufen statt das Property direkt zu setzen.
hydrate() vs. apply()
Zwei Methoden, ein entscheidender Unterschied:
// 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 erkannthydrate() 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:
$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:
$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 2Die 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
// 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
// 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
$rows = $pdo->query('SELECT * FROM users')->fetchAll(PDO::FETCH_ASSOC);
$users = $hydration->loadMultiple(new User(), $rows);
// Array von hydrierten User-Objekten mit individuellen SnapshotsIdentity
Vier ID-Strategien ohne externe Abhängigkeiten:
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 IDUUID 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:
$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:
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:
$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.
| Attribut | Ziel | Parameter |
|---|---|---|
#[Table] | Klasse | name, schema |
#[Aggregate] | Klasse | name, root |
#[Column] | Property | name, type, length, precision, scale, nullable, default, unique |
#[PrimaryKey] | Property | autoIncrement |
#[ForeignKey] | Property | referencedTable, referencedColumn, onUpdate, onDelete |
#[Relation] | Property | type ('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_caseVerzeichnisstruktur
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.phpAPI-Referenz
Hydration
| Methode | Signatur | Beschreibung |
|---|---|---|
hydrate | hydrate(object $entity, array $data): object | Befüllen + Snapshot aktualisieren |
apply | apply(object $entity, array $data): object | Befüllen ohne Snapshot-Update |
hydrateAggregate | hydrateAggregate(object $aggregate, array $data): object | Rekursive Graph-Hydration |
getChanges | getChanges(object $entity): array | Geänderte Spalten + neue Werte |
getChangedFields | getChangedFields(object $entity): array | Nur die geänderten Spaltennamen |
getSnapshot | getSnapshot(object $entity): array | Aktueller Snapshot |
clone | clone(object $entity): object | Flacher Clone |
cloneAggregate | cloneAggregate(object $entity): object | Tiefer Clone |
diff | diff(object $a, object $b): array | Unterschiede zwischen zwei Entities |
toArray | toArray(object $entity): array | Entity → flaches Array |
aggregateToArray | aggregateToArray(object $entity): array | Aggregate → verschachteltes Array |
loadMultiple | loadMultiple(object $template, array $rows): array | Batch-Hydration |
Identity
| Methode | Signatur | Beschreibung |
|---|---|---|
generateUuid4 | generateUuid4(): string | Random UUID v4 |
generateUuid5 | generateUuid5(string $namespace, string $name): string | Deterministisches UUID v5 |
generateUuid7 | generateUuid7(): string | Zeitbasiertes UUID v7 |
generateNanoId | generateNanoId(int $length = 21, string $alphabet = '...'): string | Kompakte Random-ID |
FieldMapper
| Methode | Signatur | Beschreibung |
|---|---|---|
toColumns | toColumns(array $data, array $map): array | Domain → DB Keys |
fromColumns | fromColumns(array $data, array $map): array | DB → Domain Keys (rekursiv) |
fromAggregate | fromAggregate(array $data, callable $mapProvider, string $entityName): array | Aggregate-Mapping |
Vollständiges Beispiel
Ein Repository-Use-Case mit Hydration, Change-Tracking und Identity:
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);