Spot: Die fehlende Dokumentation

Nachdem ich schon seit längerem kaum noch mit PHP gearbeitet habe, stand kurz vor Weihnachten mal wieder ein kleines privates Projekt an. Da ich mich in der Anwendungsentwicklung sonst eher in Bereichen bewege, in denen CoreData oder das Entity Framework zur Basis gehören, habe ich mich auch mal nach einem ORM-System für PHP umgesehen. Doctrine ist weit verbreitet, gilt aber auch als komplex bzw. heavy (im Sinne von Kanonen auf Spatzen). Daher fiel meine Wahl auf Spot, das recht leichtgewichtig ist und insbesondere für Menschen wie mich, die LINQ lieben gelernt haben, einen Blick wert ist. Ärgerlicherweise hält sich die offizielle Dokumentation mit Details allerdings deutlich zurück, weswegen ich im folgenden einige zusätzliche Möglichkeiten aufzeigen will.

Dieser Artikel wird bei Bedarf erweitert werden.

Einführung

Wer ORM noch nicht kennt, dem sei hier eine kurze Beschreibung gegeben:

Object Relational Mapping steht für objektrelationale Abbildung und ist effektiv nichts anderes, als eine Komponente, die Objekte (Instanzen einer Klasse) auf eine relationale Datenbank abbildet. Effektiv muss man sich als Programmierer also keine Gedanken machen, wie man die Objekte dauerhaft speichert und die Daten aus den Tabellen wieder in die Objekte bekommt, sondern überlässt dem OR-Mapper das Generieren und Ausführen der SQL-Abfragen und -Befehle sowie das Befüllen der Objekte.

Für Abfragen stellen viele OR-Mapper meistens eine eigene Abfragesprache zur Verfügung (häufig vereinfachtes SQL) oder nutzen eine von der Programmiersprache vorhandene Abfragesprache (wie LINQ). Die meisten OR-Mapper können mit mehreren Datenbanksystemen arbeiten, so dass man beispielsweise problemlos von MySQL auf SQLite wechseln kann – hilfreich insbesonders, wenn das Programm in verschiedenen Umgebungen läuft.

Insbesondere bei älteren Programmierern ist das ganze nicht unumstritten, da es in der Natur der Sache liegt, dass die ganze Geschichte nicht perfekt läuft. Wer seinen Kopf nicht nur als Hutständer benutzt, kann mit SQL ordentliche Abfragen schreiben, die mit dem OR-Mapper nur schwer nachzubauen sind. Von daher ist immer noch sinnvoll, auch SQL zu können und die generierten Abfragen auch mal anzuschauen. Die Programmierarbeit erleichtern OR-Mapper in jedem Fall, die Performance leidet aber eben manchmal darunter. In gewissen Fällen muss man deswegen auf SQL-Statements zurückgreifen.

Für wen sich das ziemlich neu anhört, sind die folgenden Punkte nicht hilfreich, stattdessen sollte man erstmal lieber ein Spot aufsetzen und mit der offiziellen Dokumentation arbeiten.

SQL anzeigen

Einer der hilfreichsten Befehle, der hinter jede Abfrage gehängt werden kann, ist toSql(). Er zeigt das Prepared Statement an, also die generierte SQL-Query mit Fragezeichen anstelle der Parameterwerte.

echo $mapper->where([ 'id' => 15 ])->toSql();

Das ist besonders dann sinnvoll, wenn man nicht für jede Möglichkeit durchprobieren möchte, ob auch die richtigen Ergebnisse kommen.

WHERE-Klauseln

Auch die Anleitung zu komplexen Abfragen ist leider nicht besonders hilfreich. Eine komplexe Verschachtelung ist aufgrund der Methodenverkettung zwar nicht möglich, aber ein paar mehr Möglichkeiten, als die Anleitung hergibt, existieren dennoch.

Verkettungstyp

Zunächst mal lässt sich in der Where-Methode angeben, um welchen Verkettungstyp es sich handelt. Dazu übergibt man der Methode als zweiten Parameter beispielsweise ein 'OR' (Standardwert ist 'AND'). Das Beispiel aus der offiziellen Dokumentation lässt sich nämlich auch einfacher schreiben.

SELECT * FROM posts WHERE status = 'published'
                       OR status = 'draft'

Laut Dokumentation:

$mapper->where([ 'status' => 'published' ])
       ->orWhere([ 'status' => 'draft' ]);

Einfacher:

$mapper->where([ 'status' => 'published', 'status' => 'draft' ], 'OR');

Aber eigentlich geht es auch mit dem IN-Operator (WHERE STATUS IN ('published', 'draft')):

$mapper->where([ 'status' =>
                    [ 'published', 'draft' ]
               ]);

Klammerung

Die OrWhere-Methode erfüllt wie die AndWhere-Methode eigentlich eher den Zweck der Klammerung. Will man beispielsweise folgende Abfrage schreiben, benötigt man sie.

SELECT * FROM table WHERE (a=1 OR b=2)
                      AND (c=3 OR d=4)
$mapper->where([ 'a' => 1, 'b' => 2 ], 'OR')
       ->andWhere([ 'c' => 3, 'd' => 4 ], 'OR');

Die daraus generierte Abfrage entspricht genau der Vorgabe, denn die im Array übergebenen Kriterien werden in eine Klammer gefasst. Daher ist auch orWhere() wichtig, beispielsweise wenn im obigen Beispiel OR und AND vertauscht werden.

SELECT * FROM table WHERE (a=1 AND b=2)
                       OR (c=3 AND d=4)
$mapper->where([ 'a' => 1, 'b' => 2 ])
       ->orWhere([ 'c' => 3, 'd' => 4 ]);

Vergleichsoperatoren

Wer mal einen Blick in parseWhereToSQLFragments() der Query.php riskiert, kann auch Rückschlüsse darüber ziehen, welche Vergleichsoperatoren es noch gibt, über die die offizielle Dokumentation Stillschweigen bewahrt.

:like LIKE
:fulltext MATCH(…) AGAINST (…)
:fulltext_boolean MATCH(…) AGAINST (… IN BOOLEAN MODE)
~=, =~; :regex REGEXP

Für ein Like muss also folgendes verwendet werden:

$mapper->where([ 'name :like' => 'Ann%' ]);

Eigenes SQL

Mit der Methode whereFieldSql() lassen sich auch SQL-Statements für ein Feld übergeben. Wichtig ist dabei, dass diese Methode erst im Query-, aber nicht im Mapper-Objekt verfügbar ist, d.h. wenn vorher kein where() erfolgt, muss man zunächst all() verwenden.

$x = 5; $y = 10;
$mapper->all()
       ->whereFieldSql('field',
                       'BETWEEN ? AND ?',
                       [ $x, $y ]);
WHERE `field` BETWEEN 5 AND 10

NULL-Checks

Eines meiner häufigeren Probleme mit SQL-Statements ist, dass die beiden folgenden Abfragen NULL zurückgeben.

SELECT 1 = NULL;
SELECT 1 != NULL;

Die richtigen Vergleichsoperatoren lauten natürlich IS NULL und IS NOT NULL.

Problematisch wird das ganze dann, wenn man ein optionales (nullable) Feld hat und alle Zeilen will, bei denen das Feld nicht einem bestimmten Wert enspricht.

$mapper->where([ 'x !=' => 12 ]);
SELECT * FROM `table` WHERE `x` != 12

Es kommen nämlich gar keine Ergebnisse zurück. Das Verhalten für die NULL muss also definiert werden. Wer meint, dass 12 nicht der NULL entspricht, muss also abfragen, wo der Wert NULL oder ungleich 12 ist.

SELECT * FROM `table` WHERE `x` IS NULL
                         OR `x` != 12

In Spot gibt es dafür zwei Varianten, wie auch schon im Abschnitt zu den WHERE-Klauseln zu lesen.

$mapper->where([ 'x' => null ])
       ->orWhere([ 'x !=' => 12 ]);
$mapper->where([ 'x' => null, 'x !=' => 12 ], 'OR');

Die zweite Variante benötigt man vor allem dann, wenn noch weitere Bedingungen mit einem AND kombiniert werden sollen.

$mapper->where([ 'y' => 'hallo' ])
       ->andWhere([ 'x' => null, 'x !=' => 12 ], 'OR');
SELECT * FROM `table` WHERE `y`='hallo'
                        AND (`x` IS NULL OR `x`!=12)

Zugegeben, etwas verwirrend ist es schon mit dem andWhere… OR, aber es funktioniert.

GROUP, HAVING

Spot kann auch GROUP BY und HAVING verarbeiten. Für letzteres lässt sich im zweiten Parameter wie bei der Where-Methode ein Verkettungstyp angeben.

$x = 5; $y = 10;
$mapper->all()
       ->group([ 'x', 'y' ])
       ->having([ 'x >' => $x, 'y >' => $y ], 'OR');
SELECT * FROM `table`
         GROUP BY `x`, `y`
         HAVING `x` > 5 OR `y` > 10

Mehrspaltige Primärschlüssel

Es gibt gute Gründe, mehrspaltige Primärschlüssel zu definieren, doch Spot kann damit nicht umgehen. Viel schlimmer noch: Es wird sich nicht beschweren.

Wer für mehrere Felder primary auf true setzt, erhält zwar genau das Datenbankschema, das er will. Doch bei den ersten Aktualisierungen kommt dann die traurige Überraschung: Spot verwendet in der Where-Klausel nur das letzte Feld, das mit primary gekennzeichnet ist und aktualisiert daher ggf. gleich mehrere Datensätze. Ein id-Feld bleibt also unausweichlich.

JOINs

Spot kann prinzipiell keine Joins, unterstützt aber mit der Query-Methode eigene SQL-Statements, die natürlich auch Joins (und Aggregatfunktionen) beinhalten können. Der Nachteil: Spot lässt keinen Einfluss auf die abgefragten Felder zu. Nur Felder, die in der Entity existieren, werden mit gleichnamigen aus der Query befüllt.

Mit ein bisschen Workaround kann man das ganze jedoch nutzbar machen: Man muss nur eine weitere Entity definieren, die die Felder enthält, die sich aus dem Join ergeben, und ruft einfach für deren Mapper die Query-Methode auf. Da erst mit der Migrate-Methode des Mappers die passende Tabelle generiert wird, muss man nur sicherstellen, dass diese nie aufgerufen wird.

Änderungen feststellen

Mit IsModified lassen sich Änderungen an einer Entity bezogen auf ein bestimmtes Feld oder das ganze Objekt feststellen.

$fieldChanged = $entity->isModified('field');
$anyFieldChanged = $entity->isModified();

Mit DataModified und DataUnmodified lassen sich auch alter bzw. neuer Wert abfragen.

$entity->field = 5;
$mapper->save($entity);
$entity->field = 10;
$oldVal = $entity->dataUnmodified('field');
          // $oldVal == 5
$newVal = $entity->dataModified('field');
          // $newVal == 10

Fehler feststellen

Mit den Error-Methoden lassen sich Validierungsfehler auslesen.

if ($entity->hasErrors() &&
    $entity->hasErrors('field')) {
    var_dump($entity->errors('field'));
}

Update vom 29. April 2016

Löschen

Obwohl die Dokumentation von Basic CRUD Operations redet, wird nicht beschrieben, wie man löschen kann. Der Mapper kennt jedoch durchaus eine Delete-Methode. Dieser kann man ein einzelnes Objekt übergeben:

$mapper->delete($entity);

Mehrere Objekte können durch Angabe einer Where-Bedingung gelöscht werden, wobei sich diese Bedingung nicht wie bei oben beschriebenen Abfragen anpassen lässt, sie wird immer mit AND verknüpft:

$mapper->delete([ 'size >' => 12 ]);

Ereignisse

Oft ist es sinnvoll, nach dem Löschen dem Objekt noch hinterherzuräumen und andere Sachen zu löschen. Dafür existieren auch in der Dokumentation nicht erwähnte Ereignisse für vor und nach dem Löschen. Diese werden jedoch nochmals danach unterschieden, ob das Objekt direkt oder über eine Where-Bedingung gelöscht wird, sodass insgesamt vier Ereignisse existieren:

  • beforeSave
  • beforeSaveConditions
  • afterSave
  • afterSaveConditions

Die vor-Methoden können zudem einen Bool zurückgeben und somit das Löschen verhindern, wenn False zurückgegeben wird.

Im Gegensatz zu den anderen Ereignissen existiert das Objekt nach dem Löschen nicht mehr, weswegen alle dieser Ereignisse im ersten Parameter eine Entity oder ein Array der (ehemaligen) Werte übergeben bekommen können. Das ist wichtig, weil man den ersten Parameter deswegen einerseits nicht auf den Entity-Typ einschränken darf und andererseits eine Fallunterscheidung durchführen muss. In der Praxis scheint es aber so zu sein, dass zumindest in den nach-Methoden sowieso immer ein Array übergeben wird.

class MyEntity {
  [...]

  public static function events(EventEmitter $eventEmitter) {
    $afterDeleteFunction = function ($entityOrArray, Mapper $mapper, $result) {
      if (is_array($entityOrArray)) {
        printf("MyEntity #%d deleted.", $entityOrArray['id']);
      }
      else {
        printf("MyEntity #%d deleted.", $entityOrArray->id);
      }
    };

    $eventEmitter->on('afterDelete', $afterDeleteFunction);
    $eventEmitter->on('afterDeleteConditions', $afterDeleteFunction);
  }
}