Home-Produkte-Testarea-Kontakt-Datenschutz-Aktualisiert: 29-Apr-2010
< Voriger Tag   Nächster Tag >

Donnerstag, 29. April 2010

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():

  1. Direkt hinter der Spaltendeklaration
  2. Am Ende der SQL-Tabelle
  3. 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;
  1. Array-Element mit Index 1 wird entfernt
  2. 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 mit update() 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.

[Direktlink]

< Voriger Tag   Nächster Tag >

  RSS V0.91

<April 2010 >
   01020304
05060708091011
12131415161718
19202122232425
2627282930  

Home-Produkte-Testarea-Kontakt-Datenschutz-Aktualisiert: 29-Apr-2010
(C) 2000-2018 by Sven Drieling