PHP: RuBisCO ORM -- Beziehungen deklarieren
In diesem Posting geht es um die Lösung der Probleme aus 1:n-Beziehung.
Statt FOREIGN KEY
dient nun das eigene
R:RELATION
zum Definieren der Beziehungen zwischen den
Objekten und FOREIGN KEY
nur noch auf Datenbankebene
zur Definition der Fremdschlüssel.
R:RELATION([relation type], [name of member variable], [source], [relation to], [fetchmode], [lazy])
Einsetzen könnte man R:RELATION()
:
- Direkt hinter der Spaltendeklaration
- Am Ende der SQL-Tabelle
- Getrennt von den SQL-Tabellen
CREATE TABLE IF NOT EXISTS users ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY R:RELATION(1:n, tasks, tasks.assigned), login VARCHAR(256) NOT NULL, R:RELATION(1:n, tasks, id, tasks.assigned) ) ENGINE=INNODB; CREATE TABLE IF NOT EXISTS tasks ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, task VARCHAR(256) NOT NULL, assigned INT, FOREIGN KEY (assigned) REFERENCES users(id) ON UPDATE RESTRICT ON DELETE SET NULL, INDEX (assigned) ) ENGINE=INNODB; R:RELATION(1:n, tasks, users.id, tasks.assigned)
Die erste Variante ist mir zu unübersichtlich. Bei der zweiten
kann man gut sehen, dass sich eine Tabelle auf eine weitere bezieht
und es betont IMHO stärker, dass tasks
eine
Membervariable der Users
-Klasse ist.
Die dritte Variante hat den Vorteil, dass die SQL-Deklarationen und der eigene Befehl gut voneinander getrennt sind. Ich werde als erstes die dritte Variante implementieren und die erste fällt ganz weg.
Parametervarianten
1:n
ist eventuell etwas umständlich zu parsen, weil
der Doppelpunkt bereits als Namensraumtrennen R:
dient. Eventuell machen für den Beziehungstyp dann Schlüsselwörter
wie OneToOne, OneToMany
mehr Sinn.
Benannte Parameter haben den Vorteil, dass die Definition
leichter lesbar ist, z.B. was den zweiten Parameter
tasks
angeht.
Und eine Mischung von Parameterliste und benannten Parametern, dass
die Definition kompakt bleibt und zugleich seltener genutzte
Parameter leichter zu verstehen sind: lazy: true
statt
nur true
und sich später beliebig viele Parameter
hinzufügen lassen.
R:RELATION(1:n, tasks, user.id, tasks.assigned) R:RELATION(1:n, user.id AS tasks, tasks.assigned, join|fetch) R:RELATION(OneToMany AS tasks, user.id, tasks.assigned) R:RELATION(1:n, memberVariable: tasks, source: user.id, relationTo: tasks.assigned, fetchMode: join) R:RELATION(1:n, tasks, user.id, tasks.assigned, lazy: true)
Ich werden im ersten Schritt die Parameterliste implementieren und dann später die benannten Parameter dazunehmen.
RelationArray
Statt des PHP-Arrays (array()
) in der ersten
1:n Implementierung
wird es in der nächsten die eigene Klasse
RelationArray
geben. Diese implementiert das
ArrayAccess-,
Countable-
und Iterator-Interface
von PHP 5, so dass der ORM den Zugriff auf die Elemente selbst
verwalten und so u.a. ein Lazy Loading implementieren kann.
Als Index des RelationArrays
dient der
PRIMARY KEY
.
class User { protected $_id = null; protected $_login = null; protected $_tasks = new RelationArray(); }
Beispiele
// Zuweisung $user->tasks[1] = new Task(1); // Index und id müssen übereinstimmen $user->tasks[] = new Task(2); // Zuweisung erfolgt an Index 2 $user->tasks[] = new Task(); // Änhängen -- Index und id werden erzeugt // Ausgabe // User: alice - task: Implement ROLLBACK for nested transactions echo "User: {$user->login} - task: {$user->tasks[1]->task}\n"; foreach($user->tasks as $task) { echo $task->task, "\n"; } foreach($user->tasks as $key => $value) { echo $key == $value->id, "\n"; } foreach($user->tasks as $id => $task) { echo $id == $task->id, "\n"; } // Ändern $user->tasks[1]->task = 'Geänderte Task-Beschreibung'; $user->update(); // Drei Varianten möglich $user->tasks->update(); $user->task[1]->update(); // Löschen // Bevorzugt $user->tasks->delete(); // Löscht alle zugewiesenen Tasks aus Array und Datenbank $user->tasks->delete(1); // Löscht Task mit id = 1 aus Array und Datenbank $user->tasks->delete("WHERE task.status = 'BOGUS'"); // Funktioniert auch $user->tasks[1]->delete(); // Löscht task[1] aus Array und Datenbank unset($user->tasks[1]); // Ebenfalls - ruft intern $user->tasks[1]->delete() auf // Aus Array rausnehmen/entfernen $task = $user->tasks->remove(1); // Entfernt tasks[1] aus Array (keine Löschung) $tasks = $user->tasks->remove(); // Entfernt alle tasks aus Array (keine Löschung) // und liefert ein Array zurück // Select $user->tasks->select("WHERE type = 'BUGFIX' ORDER BY dateCreated DESC LIMIT 10, 20"); // Query $user->tasks->query("SELECT * FROM tasks WHERE type = 'BUGFIX' ORDER BY dateCreated DESC LIMIT 10, 20"); // Die query()-Variante hat den Vorteil einheitlich mit der weiter unten // beschriebenen API zum Einlesen übereinzustimmen. Sieht // an dieser Stelle aber etwas seltsam aus, da ohnehin nur tasks // gelesen werden dürfen. // count() echo count($user->tasks); // Anzahl der Tasks // isset() if(isset($user->tasks[42]) { echo "Task mit id '42' existiert\n"; } else { echo "Task mit id '42' existiert nicht\n"; } // sort() - geht leider nicht, da RelationArray kein PHP array() ist. // Alternativen: Ableitung von ArrayObject der SPL oder // Implementierung eigener sort()- etc. Methoden. sort($user->tasks); // find() // filter()
id ändern
Die id der tasks-Objekte
darf geändert werden -
dabei wird zugleich automatisch der Index
angepasst.
$user->tasks[1]->id = 5; // Nun tasks[5] echo $user->tasks[5]->tasks; // Kein tasks[1] mehr da echo $user->tasks[1]->task;
- Array-Element mit Index 1 wird entfernt
- task-Objekt wird Index 5 zugewiesen
Notiz: Die Task-Objekte müssen dazu wissen, zu welchen
RelationArray
sie gehören.
Wichtig: id 5 darf noch nicht belegt sein (PRIMARY KEY).
$users->tasks[1]->assigned = 42
Mit der Änderung von assigned
gehört das
task
-Objekt zu einem anderen User und muss daher aus
dem RelationArray
entfernt werden:
- assigned wird geändert
task
-Objekt wird aus RelationArray entfernt- Falls der neue User geladen ist, wird das
task
-Objekt diesem User zugewiesen - Ansonsten wird das
task
-Objekt mitupdate()
in der Datenbank gespeichert und aus dem Speicher gelöscht
Alternativen
- tasks[1]->assigned nur lesbar
- Nicht zugewiesene
tasks
-Objekte werden in einem Objekt-Pool im Speicher gehalten:- Wenn ein neuer User geladen wird, wird nachgeschaut ob passende Tasks im Objekt-Pool vorhanden sind und diese dem User zugewiesen.
Ich hoffe, zumindest etwas davon lässt sich möglichst einfach implementieren.
API zum Einlesen
Beim Lesen kann mit SQL-Befehlen beschrieben werden, welche
Daten geladen werden sollen. Diese SQL-Befehle werden von einem
eigenen Parser verarbeitet, überprüft und aufbereitet bevor sie an
die Datenbank weitergegeben werden. Der Stern '*' wird dabei z.B.
zu tasks.id, tasks.task, tasks.assigned
umgewandelt.
$users = $todo->query("SELECT * FROM users WHERE login LIKE 'a%' ORDER BY login DESC LIMIT ?, ?", 10, 10);
Unterstützt wird von diesem Parser nur SELECT
ohne
JOIN
-Angaben - letztere sind ja bereits mit
R:RELATION
definiert.
Die Syntax orientiert sich an ANSI-SQL und wird vom Parser in die SQL-Implementierung des jeweiligen Datenbanksystems umgewandelt.
Notiz: Als Erweiterung könnten weitere SQL-Möglichkeiten genutzt werden, die passende Membervariablen, Methoden, Klassen etc. dynamisch zur Laufzeit erzeugen.
Möglichkeiten
$user = Todo::selectOne('User', 'WHERE id = 1'); // Statisch $user = $todo->selectOne('User', 'WHERE id = 1'); // Objekt $user = $todo->selectOne("FROM users WHERE id = 1'); $user = $todo->queryOne("SELECT * FROM users WHERE id = 1'); $users = $todo->select('FROM users'); $users = $todo->query('SELECT * FROM users'); $users = $todo->query("SELECT * FROM users WHERE login LIKE 'a%' ORDER BY login DESC LIMIT ?, ?", 10, 10); $users = $todo->query("SELECT * FROM users WHERE login LIKE 'a%' ORDER BY login DESC LIMIT :start, :perPage", 10, 10); $users = $todo->query("SELECT * FROM users WHERE login LIKE 'a%' ORDER BY login DESC LIMIT :start, :perPage", array('start' => 10, 'perPage' => 10)); $users = $todo->queryRaw('SELECT * FROM users'); // Direkte Weitergabe an Datenbank
query('SELECT ...')
gefällt mir besser als
select('WHERE ...')
. Ersteres ist zwar etwas mehr Text
als notwendig, dafür aber von der Optik näher an SQL dran und so
IMHO einfacher zu lesen. Ich tendiere zu query()
.
queryRaw()
dient zur direkten Weitergabe an die
Datenbank ohne bzw. nur mit einer geringen Verwaltung durch den
eigenen Parser.
read()
Die read()
-Methode könnte auch WHERE
unterstützen:
$user->read(1); $user->read('WHERE id = 1); $user->read('WHERE login = 'alice'); $user->read("SELECT * FROM users WHERE login = 'alice'"); $user->select(1); $user->select('WHERE id = 1); $user->query("SELECT * FROM users WHERE login = 'alice'"); $user->query(1);
Hmm, read()
durch query()
ersetzen,
damit die API einheitlicher bleibt? Die
query()
-Variante ist in diesem Fall allerdings
unnatürlich lang. Vielleicht in diesem Kontext
select()
und damit es einheitlich bleibt dann doch
$todo->select("WHERE ....)
?
query() -- Vorher automatisch speichern?
$users = $todo->query('SELECT * FROM users');
Die Objekte im RelationArray könnten geändert aber noch nicht
gespeichert sein. Durch das query('SELECT ...')
gingen
diese Änderungen dann verloren. Der ORM könnte vor einem
query()
die Daten automatisch speichern oder die
Speicherung dem Programmierer überlassen -- einstellbar über
$todo->autosave
.
Cache für SQL-Abfragen
Für bereits compilierte Abfragen wird es zur Laufzeit einen Cache geben, damit der eigene Parser unveränderte Abfragen nicht immer wieder neu compiliert.
Für Abfragen, die statisch im Programmcode stehen wie in den obigen Beispielen, könnte auch schon während der Generierung der Klassen zusätzlich die statischen Abfragen compiliert und als vordefinierter Cache-Inhalt mitgegeben werden.
Optimierung: Dirty/Modified-Flag
Das Dirty-Flag ist nach dem Laden gelöscht. Wenn Eigenschaften
der Objekte geändert oder Änderungen am RelationArray durchgeführt
werden, wird es gesetzt. So brauchen bei insert(), update(),
...
nur die Objekte gespeichert werden, die verändert
wurden.
Bei insert(), update(), ...
wird das Dirty-Flag
wieder gelöscht.
Zyklische Abhängigkeiten
Einem User können beliebig viele Tasks zugeordnet werden. Wenn
zusätzlich die 1:1-Beziehung aus dem 1:1-Beispiel hinzugenommen
wird, kann einem Task wiederum ein User zugeordnet werden. Alle im
Array $users->tasks
enthaltenen Tasks könnten also
eine Referenz auf dem User enthalten: $task->userRef =
$user
.
Wenn ein solcher Task gespeichert wird, ruft er das Speichern des Users auf und dieses Speichern wiederum das Speichern der Tasks, die wieder den User speichern usw. -- eine Endlosschleife. Die müsste erkannt und verhindert werden. Fragt sich nur wie und vor allem wie zwischen einer solchen Schleife und zwei einzelnen, unabhängigen Aufrufe unterschieden werden soll?
- beginTransaction()
- Marker wird erzeugt
$user->update()
- Marker wird im $user-Objekt gesetzt$user->tasks->update()
$user->tasks[1]->userRef->update()
-- Wieder beim User. Marker aus dieser Transaktion schon gesetzt? -> kein update() mehr aufrufen- commitTransaction() -- Marker löschen
Oder ohne Transaktion auf Objekt-Ebene:
- Objekt-Anfang: Marker für Objekt erzeugen
- Objekt-Ende: Marker für Objekt löschen
- Objekt weiterer Aufruf: Test, ob Marker schon vorhanden?
Hmm, macht zumindest den Eindruck irgendwie lösbar zu sein. Alternative ist die zyklischen Abhängigkeiten nicht zu unterstützen und damit bei der Definition der Klassen zu vermeiden oder dort automatisch zu erkennen.
Fazit
Macht mir einen brauchbaren Eindruck. Die Benutzung ist einfach
und intuitiv und umsetzbar scheint es in der Form auch zu sein --
dankt ArrayAccess
und Co. in PHP 5.