Schlagwort: Active-Record

Das Active-Record Prinzip

27. Mai 2010

Das Active-Record Prinzip
Im Rahmen des Kursus objektorientierte Programmierung mit PHP und MySQL wurde in den vergangenen Tagen über die verschiedenen Formen der Objektrelationalen Abbildung (ORM) diskutiert. Das hier am häufigsten gewählte Prinzip ist nach wie vor Active-Record. Viele Frameworks wie bspw. CakePHP, PHP on TRAX oder Doctrine nutzen diese Form des ORM. Grund genug, sich einmal genauer anzusehen, was sich hinter Active-Record verbirgt, und welche Möglichkeiten PHP bietet, dieses Prinzip umzusetzen.

Active-Record basiert auf der Annahme, dass es ein Domänen-Modell gibt, bei dem zu jeder Klasse des Objektmodells eine korrespondierende Datenbanktabelle existiert. Jede Spalte der Tabelle soll dabei als eine Eigenschaft/Attribut des Domänenobjektes abgebildet werden. Das Domänenobjekt wird zudem um die folgende Methoden erweitert: Erstellen eines Datenbankeintrages aus den Werten des Objektes (INSERT), Erstellen von Objekten auf Basis einer Datenreihe (SELECT), Aktualisieren eines Datensatzes (UPDATE) und Löschen eines Datensatzes (DELETE) . Zusammengefasst werden diese Methoden unter dem Begriff CRUD-Operationen (create, read, update und delete).
Ein Active-Record Objekt enthält demnach sowohl die Geschäftslogik des Domänenobjekts als auch die Persistierungslogik (Datenbankoperationen). Active-Record eignet sich immer dann besonders gut, wenn die Domänenlogik nicht zu komplex ist und wenn ein isomorphes Schema vorliegt, d.h. die Active-Record Objekte tatsächlichen Datenbanktabellen entsprechen. Der Objektentwurf ist damit eng mit dem Datenbankentwurf verbunden, “einseitige” Änderungen können bei der (Weiter-)Entwicklung daher leicht zu Problemen führen.

Relationen bei Active-Record

Zwischen den einzelnen Objekten können Beziehungen (Relationen) bestehen. Die Beziehung zwischen zwei Active-Record-Objekten steht in direktem Zusammenhang mit der Beziehung zwischen den Datenbanktabellen, die diese Domänenobjekte repräsentieren. Aus Sicht der Datenbank gibt es drei Beziehungstypen: eins-zu-viele (engl.: one-to-many, 1:n), eins-zu-eins (engl.: one-to-one, 1:1) und viele-zu-viele (engl.: many-to-many, n:m). Die meisten Umsetzungen von Active-Record unterteilen dabei wie folgt:

  • BELONGS_TO (gehört): Wenn die Beziehung zwischen den Tabellen A und B eins-zu-viele ist, dann gehört B A (z.B. Nachtricht gehört User).
  • HAS_MANY (hat viele): Wenn die Beziehung zwischen der Tabelle A und B eins-zu-viele ist, dann hat A viele B (z.B. User hat viele Nachricht(en)).
  • HAS_ONE (hat ein): Wenn A höchstens ein B hat (z.B. User hat höchstens ein Profil)
  • HAS_AND_BELONGS_TO_MANY oder MANY_MANY (viele viele): Dies entspricht der viele-zu-viele-Beziehung (n:m-Beziehung) bei Datenbanken. Es wird eine zusätzliche Verbindungstabelle benötigt, um die viele-zu-viele Beziehung auf jeweils eine eins-zu-viele-Beziehungen herunterzubrechen. (z.B. die Kontaktbeziehung zwischen User(n))

Die Implementierung dieser Beziehungen in Active-Record kann recht problematisch sein. Wenn man sich eine n:m Relation als SQL Statement anschaut, dann hat man es immer mit einem komplexeren JOIN zu tun. Ein solches komplexeres Statement hat den Vorteil, dass man über die so zusammengesetzen Spalten leicht filtern kann, und den Nachteil, dass man jede Menge an redundanten Daten erhält – die zudem auch noch den entsprechenden Domänenobjekten zugeordnet werden müssten.
In solchen Fällen wäre die Teilung des Statements in mehrere SQL Abfragen effizienter, die Zuordnung zu den Objekt ist leichter, redundante Daten (weil Spalten bei 1:n immer aufgefüllt werden) gäbe es nicht. Welche der beiden Ansätze der richtige ist, lässt sich nicht beantworten. Grundsätzlich gilt, je weniger Anfragen an die Datenbank, desto weniger Last, aber komplexe Anfragen kosten mehr Zeit als einfache. Einige Frameworks gehen soweit, dass sogar beide Varianten angeboten werden (bspw. Yii – Web Programming Framework). Andere Implementierungen von Active-Record verzichten ganz auf eine “automatische” Relation der Domänenobjekte und bilden die Beziehungen über virtuelle Attribute ab.

Active-Record mit PHP 5.3 und PDO

Der folgende Beispielcode zeigt und erläutert eine prototypische Umsetzung des Active-Record Prinzips. Es handelt sich dabei nur um ein “Proof of concept“, da es im Rahmen des Kurses nicht möglich ist, sich eingehender mit einem der hier bereits erwähnten Frameworks zu beschäftigen. Der dargestellte Code ist nicht vollständig. Bei Interesse an diesem Entwurf einfach eine E-Mail senden.

Bei Active-Record geht es zum einen darum, Datensätze zu verwalten, also (create, update, edit und delete), mindestens ebenso wichtig aber ist das Finden von Datensätzen, erst über das Finden sind update, edit und delete überhaupt möglich. Das finden sollte sich aber nicht auf ein Domänenobjekt beziehen, sondern unabhängig von einer konkreten Instanz funktionieren, die Klasse müsste also sowohl statisch als auch dynamisch aufrufbar sein.

Der Konstruktor – für ein spezifisches Domänenmodell

Der Konstruktor wird nur aufgerufen, wenn ein neues Objekt erzeugt wird, dabei kann man wahlweise eine ID übergeben, die dazu führt, dass diese Instanz mit den Daten aus der entsprechenden Tabelle gefüllt wird, oder einen array an Werten, um die Attribute zu setzen (für create und update).

public function __construct() {
  //setzen des namens - dient als eine moegliche basis
  //fuer den datenbanknamen
  self::$name = get_class($this);
  //pruefen, ob der constructor argumente erhalten hat
  $numargs = func_num_args();
  if($numargs > 0) {
    $args = func_get_args();
    //handelt es sich um einen integeren wert, ist dieses
    //ein primaerschlussel, und das objekt wird mit den
    //entsprechenden daten gefuellt
    if(is_int($args[0])) {
     $this->__setById($args[0]);
    } else {
     //in allen anderen faellen uebernimmt eine spezielle
     //funktion das fuellen der eigenschaften
     call_user_func_array(
      array($this, '__setter'),
      func_get_args()
     );
   }
 }
}
 
/* Aufgerufen ueber den Konstruktor,
 * erwartet eine ID, uebertraegt die
 * Datenbank abfrage in die aktuelle
 * Objektinstanz
 */
protected function __setById($id) {
  //fuer den fall, dass es noch keine datenbank verbindung
  //gibt, wird eine erstellt, db ist eine statische
  //klasse, die ueber den connect ein PDO objekt liefert
  if(!self::$_db) {self::$_db = db::connect();}
  //ermittlung des korrekten tabellen namens
  $name = self::tableName();
  $sql = 'SELECT * FROM '.$name.' WHERE '.self::$_pk .'=?';
  $result = self::$_db->prepare( $sql );
  if(!$result->execute(array($id))) {
   $error = $result->errorInfo();
   throw new Exception('Could not initialize - reason:'.$error[2]);
  }
  //pdo fetch_into - das ergebnis wird direkt in diese
  //objekt instanz uebertragen
  $result->setFetchMode(PDO::FETCH_INTO,$this);
  $result->fetch();
}
 
/*
 * Der setter kann mit einer Kombination von
 * einzelwerten (eigenschaft/wert) oder einem
 * bzw. zwei arrays umgehen, die so entstehende
 * gesamtinformation wird an die eigentlichen
 * attribute uebertragen
 */
protected function __setter($one, $two = null) {
  $data = array();
  if (is_array($one)) {
    if (is_array($two)) {
     $data = array_combine($one, $two);
    } else {
     $data = $one;
    }
  } else {
   $data = array($one => $two);
  }
  foreach ($data as $name => $value) {
    $this->$name = $value;
  }
}

Wer bin ich eigentlich?

Die Active-Record Klasse ist eine Basis Klasse, von der dann die konkreten Domänenmodelle abgeleitet werden. Da sie sowohl statisch als auch dynamisch aufgerufen werden kann, ist es für die Basis Klasse von enormer Wichtigkeit zu wissen, “wer da aufruft” – denn nur über diese Information kann die richtige Tabelle angesprochen werden und können die korrekten Objekte initialisiert werden (bei find). Beim statischen Aufruf ist das vor PHP 5.3 so einfach nicht möglich gewesen, seit 5.3 gibt es die Funktion get_called_class, die genau das leisten kann.

/* Ermittelt den korrekten tabellnamen
 * anhand des Klassennamens und ggf.
 * einem $table Attribut
 */
protected static function tableName() {
 //klassenname, funktioniert auch bei statischem aufruf
 $name = get_called_class();
 //fuer den fall, dass sich tabellenname und klassenname
 //unterscheiden gibt es einen table attribut in den
 //kinderklassen.
 $table = (!empty(self::$table)) ? self::$table : $name::$table;
 if(!empty($table)) {
  return $table;
 }
 //sonst ist klassenname gleich tabellenname
 return $name;
}

Die Magie – der Einsatz der PHP magics

In der Basisklasse werden die Getter und Setter für die einzelnen Attribute “einfach” über die entsprechenden Magics von PHP kontrolliert. So handelt es sich bei den Domänenobjekt Eigenschaften in Wirklichkeit gar nicht um echte Attribute sondern um einen assoziativen Array.

//setzen von attributen
public function __set($property, $value) {
  // kein setzen eines primary keys (id), wenn schon vorhanden!
  if( $property == self::$_pk &&
   $this->_data[self::$_pk] !== NULL) {
    throw new Exception(
     "Setting '".strtoupper(self::$_pk)."' is immutable."
    );
  } elseif(method_exists($this,"set_{$property}")) {
     //fuer den fall, dass es eine definierte methode gibt...
     $methodName = "set_{$property}";
     $this->$methodName($value);
  } elseif (array_key_exists($property, $this->_data)) {
     // wenn es eine entsprechende eigenschaft gibt...
     $this->_data[$property] = $value;
  } else {
    //unbekannte eigenschaft...
    throw new Exception("Unkown property: '$property'.");
  }
}
 
//abholen von attributen...
public function __get($property) {
  if(method_exists($this,"get_{$property}")) {
   //fuer den fall, dass es eine definierte methode gibt...
   $methodName = "get_{$property}";
   return $this->$methodName();
  } elseif (array_key_exists($property, $this->_data)) {
   //wenn es das attribut gibt...
   return $this->_data[$property];
  } else {
   //sonst fehler
   throw new Exception("Unkown property: '$property'.");
  }
}
 
//pruefen ob es die eigenschaft gibt
public function __isset($property) {
 return isset($this->_data[$property]);
}
 
//loeschen der eigenschaft
public function __unset($property) {
  if (isset($this->_data[$property])) {
    unset($this->_data[$property]);
  }
}

Und noch mehr Magie, über __call können Methoden verarbeitet werden, die eigentlich nicht aufrufbar sind, also eigentlich nicht vorhanden, oder geschützt. Die Methoden zum Bearbeiten der Datensätze sind daher protected, sollen nur dann aufrufbar sein, wenn es eine Objektinstanz gibt. Die Methoden zum Finden von Datensätzen werden über __callStatic verarbeitet. Die Idee (nicht meine), eine Methode wie bspw. findAllByTitelAndYear(‘Titel’,2002) soll umgelenkt werden auf eine allgemeine Find-Methode, die ein entsprechendes SQL Statement zusammensetzt.

/* Zusammensetzen von SQL Conditions aus
 * dem Methodennamem, verarbeitet bisher
 * Syntax wie findByTitel_And_Year oder
 * findAllByYear_Or_Type
 */
public static function __callStatic($name,$arguments) {
 if(strpos($name, 'findBy')===0 || strpos($name, 'findAllBy')===0){
  $query = array();
  if(strpos($name, 'findBy')===0) {
   $conditions = substr(strtolower($name),6);
  } else {
   $conditions = substr(strtolower($name),9);
  }
  $or = (strpos($conditions, '_or_') !== false);
  if ($or) {
   $conditions = explode('_or_', $conditions);
  } else {
   $conditions = explode('_and_', $conditions);
  }
  foreach($conditions as $num=>$condition) {
   $query[$condition] = $arguments[$num];
  }
  if($or) {
   $query['OR'] = $query;
  } else {
   $query['AND'] = $query;
  }
  if(strpos($name, 'findBy')===0) {
   return self::find($query);
  } else {
   return self::findAll($query);
  }
 } else {
  throw new Exception("Unkown function: '$name'.");
 }
}
 
public function __call($name,$arguments) {
 //schuetzen vor statischem zugriff...
 if($name=="delete" || $name=="save") {
  $this->$name();
 }elseif(strpos($name, 'get')===0){
  //abfangen von getter methoden
  $props = explode('_',substr(strtolower($name),3));
  $r = array();
  foreach($props as $prop) {
	 $r[$prop] = $this->{$prop};
  }
  return $r;
 } elseif($name=='set') {
  //nutzen von set als aufruf der speziellen setter methode
  //also dem einfachen fuellen von einer objektinstanz
  call_user_func_array(array($this, '__setter'), $arguments);
 } elseif(strpos($name, 'set')===0){
  //abfangen von setter methoden
  $props = explode('_',substr(strtolower($name),3));
  foreach($props as $prop) {
	  $this->__setter($prop,$arguments);
  }
 } else {
  //alles andere ist ein fehler
  throw new Exception("Unkown function: '$name'.");
 }
}

Was der Basis-Klasse jetzt noch fehlt, sind die Standard Operationen Löschen, Editieren, Erstellen und die Finde bzw. Finde-alle Methoden. Erstellen, Editieren und Löschen machen nichts weiter, als die Standard-SQL Befehle anhand des Tabellennamens auszuführen. Erstellen und Editieren habe ich noch einmal in einer Methode Save gekappselt, die – je nachdem ob eine ID vorhanden ist oder nicht, Erstellen oder Editieren aufruft. Auf die Darstellung dieser Methoden sei hier verzichtet, sie bestehen wie gesagt fast nur aus einem SQL-Statement mit Platzhaltern. Finde und FindeAlle unterscheiden sich nur von der fetch Anweisung für das PDO Objekt (fetch bzw. fetchAll) daher auch hier nur eine von beiden:

public static function find($conditions=array()) {
 //verbindung zur datenbank falls notwendig
 if(!self::$_db) {self::$_db = db::connect();}
 //tabelle ermitteln
 $name = self::tableName();
 //sql string zusammenbauen
 $sql = 'SELECT * FROM '.$name;
 //setzt die bedingungen zusammen...
 $con = self::_prepareConditionString($conditions);
 if(!empty($con))  {
	$sql.= ' WHERE '.$con;
 }
 
 $result = self::$_db->prepare( $sql );
 if(!$result->execute()) {
	$error = $result->errorInfo();
	throw new Exception($error[2]);
 }
 //zielobjekt ermitteln
 $classname = (!empty(self::$name)) ? self::$name : get_called_class();
 //und nun - dank pdo - direkt ein entsprechendes objekt erstellen
 $result->setFetchMode(PDO::FETCH_CLASS,$classname);
 return $result->fetch();
}

Und was ist mit den Beziehungen? – Davon ein anderes Mal vielleicht mehr. Aber schon in diesem Zustand kann einem eine solche Active-Record Klasse viel Schreibarbeit für die gängigen CRUD Befehle abnehmen. Wie gesagt, es handelt sich hier nur um eine Beispiel Implementierung, für einen “realen” Einsatz so noch nicht geeignet. Es fehlen Validierungen, Absicherungen gegen SQL-Injections und vieles mehr. Sie soll zum spielen und probieren anregen und – ganz nebenbei – zum besseren Verständnis von PHP Magics. Verbesserungsvorschläge und Anregungen zur Weiterentwicklung sind sehr willkommen, Fragen ebenso. In diesem Sinne – viel Spass beim Programmieren.