Fluent Interface statt zusammengeklebter SQL-Strings
Ein eigener Query Builder ersetzt fragile String-Konkatenation durch ein Objekt, das SQL-Ausdrücke schrittweise, typsicher und testbar zusammensetzt. Wer versteht, wie ein Query Builder intern funktioniert, kann fertige Lösungen wie Doctrine oder Laravels Eloquent gezielter einsetzen und weiß, wo Eigenbau tatsächlich sinnvoll ist.
Inhaltsverzeichnis
- 1. Warum überhaupt einen eigenen Query Builder bauen
- 2. Architektur-Grundlagen: Fluent Interface und Method Chaining
- 3. SELECT-Queries programmatisch zusammensetzen
- 4. Parameter-Bindung: SQL-Injection im Query Builder ausschließen
- 5. JOINs abbilden: INNER, LEFT und RIGHT programmatisch
- 6. INSERT, UPDATE und DELETE über den Query Builder
- 7. Verschachtelte Bedingungen und Subqueries
- 8. Testbarkeit: Query Builder von der Datenbank entkoppeln
- 9. Query Builder im Vergleich: Eigenbau, Doctrine, rohes SQL
- 10. Zusammenfassung
- 11. FAQ
1. Warum überhaupt einen eigenen Query Builder bauen
Ein Query Builder ist ein Objekt, das SQL-Anweisungen schrittweise über Methodenaufrufe zusammensetzt, statt sie als rohe Strings zu verketten. Der naive Ansatz, WHERE-Bedingungen per .-Operator an einen SQL-String anzuhängen, funktioniert für einfache Fälle, wird aber bei dynamischen Filtern, optionalen Bedingungen und wechselnden Sortierungen schnell unübersichtlich und fehleranfällig. Ein Query Builder löst genau dieses Problem, indem er die Struktur der Abfrage intern als Objektgraph hält und erst am Ende in SQL übersetzt.
Der zweite Grund, einen eigenen Query Builder zu bauen, ist Lernzweck: Wer einmal verstanden hat, wie ein Query Builder WHERE-Klauseln, Parameter-Bindungen und JOINs intern verwaltet, versteht auch Doctrine, Laravels Eloquent Builder oder Symfonys QueryBuilder wesentlich besser. Diese Bibliotheken sind selbst nur ausgereifte, gut getestete Varianten desselben Grundmusters. In kleinen Projekten ohne ORM ist ein schlanker, selbstgebauter Query Builder zudem oft die pragmatischere Wahl als eine schwergewichtige Abhängigkeit für ein paar dutzend Abfragen.
Wichtig ist die Abgrenzung: Ein Query Builder ist kein ORM. Er bildet keine Objekt-Beziehungen ab und kennt keine Entitäten, sondern erzeugt ausschließlich SQL-Strings mit gebundenen Parametern. Diese bewusste Beschränkung macht einen Query Builder deutlich einfacher zu implementieren und zu debuggen als ein vollständiges ORM, weil jederzeit nachvollziehbar bleibt, welches SQL am Ende tatsächlich ausgeführt wird.
2. Architektur-Grundlagen: Fluent Interface und Method Chaining
Das zentrale Entwurfsmuster eines Query Builders ist das Fluent Interface: Jede Methode gibt $this zurück, sodass Aufrufe verkettet werden können, etwa $builder->select(...)->from(...)->where(...). Intern sammelt der Query Builder dabei nur Zustand in privaten Arrays und Properties, ohne sofort SQL zu generieren. Erst eine abschließende Methode wie toSql() oder get() übersetzt den gesammelten Zustand in eine ausführbare Anweisung.
Diese Trennung zwischen Zustandsaufbau und SQL-Generierung ist entscheidend für Testbarkeit und Erweiterbarkeit. Ein Query Builder, der bei jedem Methodenaufruf sofort SQL-Fragmente verkettet, lässt sich schwer erweitern, sobald zusätzliche Optionen wie GROUP BY oder HAVING hinzukommen. Bei sauberer Trennung reicht es, eine neue Property und eine zusätzliche Zeile in der toSql()-Methode zu ergänzen.
<?php
declare(strict_types=1);
/**
* Minimal fluent Query Builder skeleton.
* Collects state, only builds SQL when compile() is called.
*/
final class QueryBuilder
{
private string $table = '';
private array $columns = ['*'];
private array $wheres = [];
private array $bindings = [];
private ?int $limit = null;
private array $orderBy = [];
public function __construct(private readonly PDO $pdo)
{
}
public function table(string $table): self
{
$this->table = $table;
return $this;
}
public function select(string ...$columns): self
{
$this->columns = $columns ?: ['*'];
return $this;
}
public function where(string $column, string $operator, mixed $value): self
{
$this->wheres[] = "{$column} {$operator} ?";
$this->bindings[] = $value;
return $this;
}
public function orderBy(string $column, string $direction = 'ASC'): self
{
$this->orderBy[] = "{$column} {$direction}";
return $this;
}
public function limit(int $limit): self
{
$this->limit = $limit;
return $this;
}
public function toSql(): string
{
$sql = 'SELECT ' . implode(', ', $this->columns) . ' FROM ' . $this->table;
if ($this->wheres !== []) {
$sql .= ' WHERE ' . implode(' AND ', $this->wheres);
}
if ($this->orderBy !== []) {
$sql .= ' ORDER BY ' . implode(', ', $this->orderBy);
}
if ($this->limit !== null) {
$sql .= ' LIMIT ' . $this->limit;
}
return $sql;
}
}
3. SELECT-Queries programmatisch zusammensetzen
Sobald das Grundgerüst steht, zeigt sich der eigentliche Vorteil eines Query Builders: Abfragen lassen sich bedingt aufbauen, ohne String-Fragmente manuell zusammenzukleben. Ein typisches Szenario ist eine Produktsuche mit optionalen Filtern für Kategorie, Preisspanne und Verfügbarkeit. Ohne Query Builder müsste jede Kombination von gesetzten und nicht gesetzten Filtern als eigener SQL-String oder mit fehleranfälliger String-Verkettung abgebildet werden.
Mit dem Query Builder reicht eine einzige Kette von Methodenaufrufen, bei der jede where()-Anweisung nur dann ausgeführt wird, wenn der entsprechende Filterwert tatsächlich vorliegt. Das Resultat ist lesbarer, weil die Absicht der Abfrage im Code sichtbar bleibt, statt in String-Interpolation zu verschwinden.
<?php
declare(strict_types=1);
$builder = (new QueryBuilder($pdo))
->table('products')
->select('id', 'name', 'price', 'stock');
// Conditional filters — only applied when the value is present
if ($categoryId !== null) {
$builder->where('category_id', '=', $categoryId);
}
if ($minPrice !== null) {
$builder->where('price', '>=', $minPrice);
}
if ($onlyInStock) {
$builder->where('stock', '>', 0);
}
$builder->orderBy('price', 'ASC')->limit(20);
$sql = $builder->toSql();
echo $sql;
// SELECT id, name, price, stock FROM products
// WHERE category_id = ? AND price >= ? AND stock > ?
// ORDER BY price ASC LIMIT 20
4. Parameter-Bindung: SQL-Injection im Query Builder ausschließen
Der wichtigste Sicherheitsaspekt eines selbstgebauten Query Builders ist die konsequente Trennung von SQL-Struktur und Werten. Spaltennamen, Tabellennamen und Operatoren dürfen niemals direkt aus Benutzereingaben übernommen werden, weil sie nicht über Prepared Statements gebunden werden können. Werte hingegen, die in WHERE-Bedingungen oder INSERT-Anweisungen landen, müssen ausnahmslos als Platzhalter im SQL-String erscheinen und über PDOStatement::execute() gebunden werden.
Ein Query Builder, der diese Trennung konsequent durchsetzt, macht klassische SQL-Injection strukturell unmöglich, weil es im generierten Code gar keine Stelle mehr gibt, an der Benutzerdaten direkt in den SQL-String interpoliert werden könnten. Wichtig ist trotzdem eine Whitelist für dynamische Spaltennamen bei ORDER BY oder Spaltenauswahl, weil Platzhalter dort syntaktisch nicht erlaubt sind.
<?php
declare(strict_types=1);
final class QueryExecutor
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* Executes a built query with bound parameters, never interpolated values.
*
* @param array<int, mixed> $bindings
*/
public function run(string $sql, array $bindings): PDOStatement
{
$statement = $this->pdo->prepare($sql);
$statement->execute($bindings);
return $statement;
}
/** Whitelist check for dynamic identifiers that cannot be bound as parameters. */
public function assertValidColumn(string $column, array $allowed): void
{
if (!in_array($column, $allowed, true)) {
throw new InvalidArgumentException("Column '{$column}' is not allowed for ordering");
}
}
}
5. JOINs abbilden: INNER, LEFT und RIGHT programmatisch
Sobald mehr als eine Tabelle beteiligt ist, muss der Query Builder auch JOIN-Klauseln abbilden können. Das Grundmuster bleibt gleich: eine Methode wie join() nimmt Zieltabelle, lokale Spalte, Operator und fremde Spalte entgegen und fügt intern ein SQL-Fragment zu einem JOIN-Array hinzu. Für LEFT und RIGHT JOINs reicht ein zusätzlicher Typ-Parameter, der beim Zusammenbau der finalen Anweisung berücksichtigt wird.
Ein häufiger Designfehler bei selbstgebauten Query Buildern ist, JOINs als reine String-Konkatenation ohne Struktur zu behandeln. Besser ist eine kleine JOIN-Klasse oder ein assoziatives Array pro JOIN, damit spätere Erweiterungen wie mehrere Bedingungen pro JOIN oder verschachtelte JOINs nicht zu unlesbarem Code führen.
<?php
declare(strict_types=1);
// Extension of the QueryBuilder skeleton from section 2
final class Join
{
public function __construct(
public readonly string $type,
public readonly string $table,
public readonly string $first,
public readonly string $operator,
public readonly string $second,
) {
}
public function toSql(): string
{
return "{$this->type} JOIN {$this->table} ON {$this->first} {$this->operator} {$this->second}";
}
}
// Usage inside the builder
public function join(string $table, string $first, string $operator, string $second, string $type = 'INNER'): self
{
$this->joins[] = new Join($type, $table, $first, $operator, $second);
return $this;
}
// Example query
$sql = (new QueryBuilder($pdo))
->table('orders')
->select('orders.id', 'customers.name')
->join('customers', 'orders.customer_id', '=', 'customers.id')
->join('shipments', 'orders.id', '=', 'shipments.order_id', 'LEFT')
->toSql();
6. INSERT, UPDATE und DELETE über den Query Builder
Ein vollständiger Query Builder beschränkt sich nicht auf SELECT, sondern bildet auch schreibende Operationen ab. Für INSERT nimmt eine Methode ein assoziatives Array von Spalte-Wert-Paaren entgegen, erzeugt daraus die Spaltenliste und die passende Anzahl an Platzhaltern und bindet die Werte in derselben Reihenfolge. Für UPDATE gilt dasselbe Prinzip, ergänzt um die bereits bekannten WHERE-Bedingungen, damit nicht versehentlich die gesamte Tabelle aktualisiert wird.
Bei DELETE ist besondere Vorsicht geboten: Ein Query Builder sollte ein DELETE ohne mindestens eine WHERE-Bedingung entweder verweigern oder zumindest explizit protokollieren, weil ein vergessenes where() sonst die komplette Tabelle leert. Diese Guard-Klausel ist eine kleine Ergänzung, die in der Praxis erhebliche Schäden verhindert.
<?php
declare(strict_types=1);
public function insert(array $data): int
{
$columns = implode(', ', array_keys($data));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})";
$statement = $this->pdo->prepare($sql);
$statement->execute(array_values($data));
return (int) $this->pdo->lastInsertId();
}
public function update(array $data): int
{
$assignments = implode(', ', array_map(fn (string $col) => "{$col} = ?", array_keys($data)));
$sql = "UPDATE {$this->table} SET {$assignments}";
if ($this->wheres === []) {
throw new LogicException('Refusing UPDATE without WHERE clause');
}
$sql .= ' WHERE ' . implode(' AND ', $this->wheres);
$statement = $this->pdo->prepare($sql);
$statement->execute([...array_values($data), ...$this->bindings]);
return $statement->rowCount();
}
public function delete(): int
{
if ($this->wheres === []) {
throw new LogicException('Refusing DELETE without WHERE clause');
}
$sql = "DELETE FROM {$this->table} WHERE " . implode(' AND ', $this->wheres);
$statement = $this->pdo->prepare($sql);
$statement->execute($this->bindings);
return $statement->rowCount();
}
7. Verschachtelte Bedingungen und Subqueries
Reale Abfragen benötigen oft mehr als eine flache Kette von AND-verknüpften Bedingungen. Ein ausgereifter Query Builder unterstützt daher Gruppen von Bedingungen, die per OR miteinander verknüpft und als Ganzes geklammert werden, etwa WHERE status = 'active' AND (region = 'DE' OR region = 'AT'). Technisch löst man das, indem eine Closure an eine where()-Variante übergeben wird, die einen neuen, verschachtelten Query Builder-Zustand erzeugt und dessen SQL am Ende in Klammern einfügt.
Subqueries sind der zweite fortgeschrittene Baustein: Ein Query Builder, der eine komplette Instanz von sich selbst als Wert in einer WHERE-Bedingung akzeptiert, kann Konstrukte wie WHERE customer_id IN (SELECT id FROM customers WHERE active = 1) abbilden. Dabei müssen die Bindings der Subquery in der richtigen Reihenfolge vor die Bindings der äußeren Abfrage gestellt werden, da PDO Platzhalter strikt nach ihrer Position im SQL-String auflöst.
<?php
declare(strict_types=1);
public function whereGroup(Closure $callback): self
{
$nested = new self($this->pdo);
$callback($nested);
$this->wheres[] = '(' . implode(' OR ', $nested->wheres) . ')';
array_push($this->bindings, ...$nested->bindings);
return $this;
}
public function whereIn(string $column, self $subquery): self
{
$this->wheres[] = "{$column} IN ({$subquery->toSql()})";
array_push($this->bindings, ...$subquery->bindings);
return $this;
}
// Usage: nested OR group combined with a subquery
$activeCustomers = (new QueryBuilder($pdo))
->table('customers')
->select('id')
->where('active', '=', 1);
$orders = (new QueryBuilder($pdo))
->table('orders')
->select('id', 'total')
->whereIn('customer_id', $activeCustomers)
->whereGroup(function (QueryBuilder $q): void {
$q->where('region', '=', 'DE');
$q->where('region', '=', 'AT');
});
8. Testbarkeit: Query Builder von der Datenbank entkoppeln
Ein entscheidender Vorteil eines selbstgebauten Query Builders ist die Möglichkeit, ihn ohne echte Datenbankverbindung zu testen. Solange die toSql()- und getBindings()-Methoden reine Funktionen ohne Seiteneffekte sind, lässt sich in Unit-Tests exakt prüfen, welches SQL und welche Parameter für einen gegebenen Methodenaufruf entstehen, ohne PDO überhaupt zu instanziieren. Das beschleunigt Testläufe erheblich und macht Fehler in der SQL-Generierung sofort sichtbar, statt sie erst über fehlgeschlagene Datenbankaufrufe zu bemerken.
Für Integrationstests, die den Query Builder tatsächlich gegen eine Datenbank ausführen, empfiehlt sich SQLite im In-Memory-Modus als schnelle, isolierte Testumgebung. Da ein guter Query Builder nur Standard-SQL erzeugt, das zwischen MySQL und SQLite weitgehend kompatibel ist, lassen sich viele Tests ohne echten Datenbankserver ausführen, was die CI-Pipeline spürbar beschleunigt.
9. Query Builder im Vergleich: Eigenbau, Doctrine, rohes SQL
Die Entscheidung zwischen einem selbstgebauten Query Builder, einer etablierten Bibliothek wie Doctrine und rohem SQL hängt stark vom Projektumfang ab. Ein Eigenbau lohnt sich, wenn die Anforderungen begrenzt sind, volle Kontrolle über generiertes SQL gewünscht ist oder eine schwergewichtige Abhängigkeit vermieden werden soll. Fertige Lösungen bieten dagegen deutlich mehr Funktionsumfang, etwa Datenbank-Portabilität über mehrere Dialekte hinweg.
| Kriterium | Eigener Query Builder | Doctrine QueryBuilder | Rohes SQL |
|---|---|---|---|
| Lernkurve | Gering, eigener Code | Mittel bis hoch | Keine, aber fehleranfällig |
| Kontrolle über SQL | Vollständig | Teilweise abstrahiert | Vollständig |
| Datenbank-Portabilität | Manuell zu pflegen | Eingebaut | Keine |
| Wartungsaufwand | Selbst zu tragen | Von Community getragen | Kein Framework-Aufwand |
| Passend für | Kleine bis mittlere Projekte | Große, langlebige Projekte | Sehr einfache Skripte |
Ein selbstgebauter Query Builder ist besonders dann sinnvoll, wenn ein Projekt bewusst schlank bleiben soll und die Abfragen überschaubar sind. Für komplexe Domänenmodelle mit vielen Beziehungen lohnt sich hingegen der Umstieg auf eine etablierte Lösung, weil dort bereits Jahre an Edge-Case-Behandlung eingeflossen sind, die ein Eigenbau erst nachbauen müsste.
10. Zusammenfassung
Ein eigener Query Builder löst das Problem fragiler SQL-String-Konkatenation durch ein Fluent Interface, das Zustand sammelt und erst am Ende in SQL übersetzt. Die wichtigsten Bausteine sind Method Chaining für lesbare Aufrufketten, konsequente Parameter-Bindung gegen SQL-Injection, eine strukturierte Abbildung von JOINs und die Unterstützung verschachtelter Bedingungen für reale Abfragen.
Der größte Gewinn eines selbstgebauten Query Builders liegt in der Testbarkeit: Da SQL-Generierung und tatsächliche Ausführung getrennt sind, lassen sich Abfragen ohne Datenbankverbindung prüfen. Wer die Grenzen kennt, kombiniert einen schlanken eigenen Query Builder für kleine Projekte mit dem Umstieg auf Doctrine oder Laravels Eloquent Builder, sobald die Anforderungen an Portabilität und Funktionsumfang wachsen.
Query Builder von Grund auf bauen — Das Wichtigste auf einen Blick
Fluent Interface
Jede Methode gibt $this zurück, SQL wird erst bei toSql() oder get() generiert.
Parameter-Bindung
Werte immer als Platzhalter, Spalten- und Tabellennamen über eine Whitelist absichern.
JOINs & Subqueries
Strukturierte Join-Objekte statt String-Konkatenation, Bindings in korrekter Reihenfolge.
Testbarkeit
SQL-Generierung ohne Datenbank testen, Integrationstests mit SQLite in-memory.