DbQuery
SQL schreiben, das auf jeder Datenbank läuft, typsicher, fluent und injektionsfrei.
Einführung
SQL von Hand zu schreiben ist fehleranfällig. SQL mit String-Konkatenation zusammenzubauen ist gefährlich. Und ORMs abstrahieren oft so weit, dass man die Kontrolle über das generierte SQL verliert.
jardissupport/dbquery geht einen anderen Weg: Ein Fluent Query Builder, der typsichere PHP-Method-Calls in korrektes, dialect-spezifisches SQL übersetzt. Derselbe Builder-Code erzeugt MySQL, PostgreSQL oder SQLite, mit allen Eigenheiten des jeweiligen Dialekts. Prepared Statements mit korrekter Binding-Reihenfolge sind der Standard, nicht die Ausnahme.
- Multi-Dialect — MySQL/MariaDB, PostgreSQL und SQLite aus einem Builder
- SELECT, INSERT, UPDATE, DELETE — vier spezialisierte Builder mit Fluent API
- Prepared Statements — automatisches Parameter-Binding, korrekte Reihenfolge garantiert
- CTEs, Window Functions, JSON-Conditions — auch fortgeschrittene SQL-Features sind abgedeckt
- Subqueries — in SELECT, FROM, WHERE IN und JOINs
- Bracket-Gruppierung — beliebig verschachtelte WHERE-Bedingungen
- SQL-Injection-Schutz — Validierung und Escaping eingebaut
Installation
composer require jardissupport/dbqueryGitHub: jardisSupport/dbquery
Grundlegende Nutzung
SELECT
use JardisSupport\DbQuery\DbQuery;
$query = (new DbQuery())
->select('id, name, email')
->from('users')
->where('status')->equals('active')
->orderBy('name')
->limit(10)
->sql('mysql', prepared: true);
// PDO ausführen
$stmt = $pdo->prepare($query->sql());
$stmt->execute($query->bindings());INSERT
use JardisSupport\DbQuery\DbInsert;
$insert = (new DbInsert())
->into('users')
->fields('name', 'email', 'status')
->values('Anna', 'anna@example.com', 'active')
->values('Bob', 'bob@example.com', 'pending')
->sql('mysql', prepared: true);UPDATE
use JardisSupport\DbQuery\DbUpdate;
$update = (new DbUpdate())
->table('users')
->set('status', 'inactive')
->set('updated_at', Expression::raw('NOW()'))
->where('last_login')->lower('2024-01-01')
->sql('mysql', prepared: true);DELETE
use JardisSupport\DbQuery\DbDelete;
$delete = (new DbDelete())
->from('users')
->where('status')->equals('banned')
->and('created_at')->lower('2023-01-01')
->sql('mysql', prepared: true);SQL-Generierung
Jeder Builder hat eine sql()-Methode mit drei Parametern:
->sql(string $dialect, bool $prepared = true, ?string $version = null)| Parameter | Werte | Beschreibung |
|---|---|---|
$dialect | 'mysql', 'mariadb', 'postgres', 'sqlite' | Ziel-Datenbank |
$prepared | true (Standard) | true → DbPreparedQuery, false → SQL-String (Debugging) |
$version | null (Standard) | Datenbankversion (z.B. '8.4' für MySQL) |
Prepared Statements (Produktion)
$prepared = $query->sql('mysql', prepared: true);
$prepared->sql(); // "SELECT * FROM `users` WHERE status = ?"
$prepared->bindings(); // ['active']
$prepared->type(); // 'mysql'
// Mit PDO
$stmt = $pdo->prepare($prepared->sql());
$stmt->execute($prepared->bindings());Raw SQL (Debugging)
$raw = $query->sql('mysql', prepared: false);
// "SELECT * FROM `users` WHERE status = 'active'"WHERE-Bedingungen
Vergleichsoperatoren
->where('age')->equals(25) // WHERE age = ?
->where('age')->notEquals(25) // WHERE age != ?
->where('age')->greater(18) // WHERE age > ?
->where('age')->greaterEquals(18) // WHERE age >= ?
->where('age')->lower(65) // WHERE age < ?
->where('age')->lowerEquals(65) // WHERE age <= ?NULL-Checks
->where('deleted_at')->isNull() // WHERE deleted_at IS NULL
->where('email')->isNotNull() // WHERE email IS NOT NULLLIKE
->where('name')->like('%anna%') // WHERE name LIKE ?
->where('email')->notLike('%test%') // WHERE email NOT LIKE ?IN / NOT IN
->where('status')->in(['active', 'pending']) // WHERE status IN (?, ?)
->where('role')->notIn(['banned', 'suspended']) // WHERE role NOT IN (?, ?)
// Leeres Array: sicheres Verhalten
->where('id')->in([]) // WHERE 1=0 (immer false)
->where('id')->notIn([]) // WHERE 1=1 (immer true)BETWEEN
->where('age')->between(18, 65) // WHERE age BETWEEN ? AND ?
->where('age')->notBetween(18, 65) // WHERE age NOT BETWEEN ? AND ?AND / OR Chaining
->where('status')->equals('active')
->and('age')->greater(18) // AND age > ?
->or('role')->equals('admin') // OR role = ?Gruppierung mit Klammern
Öffnende Klammern werden als Parameter an where/and/or übergeben, schließende an den Operator:
// WHERE (status = ? AND age > ?) OR role = ?
->where('(status')->equals('active')
->and('age')->greater(18, ')')
->or('role')->equals('admin')
// WHERE status = ? AND (role = ? OR role = ?)
->where('status')->equals('active')
->and('(role')->equals('admin')
->or('role')->equals('moderator', ')')EXISTS / NOT EXISTS
$sub = (new DbQuery())
->select('1')
->from('orders', 'o')
->where('o.user_id')->equals(Expression::raw('u.id'));
(new DbQuery())
->select('*')
->from('users', 'u')
->where('u.active')->equals(true)
->exists($sub)
// WHERE u.active = ? AND EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)Expressions — Raw SQL
Für Spaltenreferenzen, Funktionsaufrufe und berechnete Werte, die nicht als Parameter gebunden werden sollen:
use JardisSupport\DbQuery\Data\Expression;
// Als Feld in WHERE
->where(Expression::raw('LOWER(name)'))->equals('anna')
->where(Expression::raw('YEAR(created_at)'))->equals(2024)
->where(Expression::raw('price * quantity'))->greater(1000)
// Als Wert (wird NICHT escaped)
->where('price')->greater(Expression::raw('cost * 1.2'))
->where('updated_at')->equals(Expression::raw('NOW()'))
// In UPDATE SET
->set('counter', Expression::raw('counter + 1'))
->set('updated_at', Expression::raw('NOW()'))
// In INSERT
$insert->set(['updated_at' => Expression::raw('NOW()')]);Nur für vertrauenswürdige Werte
Expression-Inhalt wird nicht escaped. Niemals User-Input in Expression::raw() verwenden.
JOINs
Alle JOIN-Typen
->innerJoin('orders o', 'u.id = o.user_id')
->leftJoin('profiles', 'u.id = p.user_id', 'p') // separater Alias
->rightJoin('departments d', 'u.dept_id = d.id')
->fullJoin('archive a', 'u.id = a.user_id') // nur PostgreSQL
->crossJoin('settings') // CROSS JOIN ohne ONSubquery-JOINs
$orderStats = (new DbQuery())
->select('user_id, COUNT(*) as order_count, SUM(total) as revenue')
->from('orders')
->where('status')->equals('completed')
->groupBy('user_id');
(new DbQuery())
->select('u.name, s.order_count, s.revenue')
->from('users', 'u')
->leftJoin($orderStats, 'u.id = s.user_id', 's')Dialect-Einschränkungen
| JOIN-Typ | SELECT | UPDATE | DELETE |
|---|---|---|---|
| INNER JOIN | Alle | Nur MySQL | Nur MySQL |
| LEFT JOIN | Alle | Nur MySQL | Nur MySQL |
| RIGHT JOIN | Alle | — | — |
| FULL OUTER JOIN | Nur PostgreSQL | — | — |
| CROSS JOIN | Alle | — | — |
Subqueries
FROM-Subquery
$sub = (new DbQuery())
->select('dept, AVG(salary) as avg_salary')
->from('employees')
->groupBy('dept');
(new DbQuery())
->select('*')
->from($sub, 'dept_stats')
->where('dept_stats.avg_salary')->greater(50000)SELECT-Subquery (korreliert)
$postCount = (new DbQuery())
->select('COUNT(*)')
->from('posts', 'p')
->where('p.user_id')->equals(Expression::raw('u.id'));
(new DbQuery())
->select('u.id, u.name')
->selectSubquery($postCount, 'post_count')
->from('users', 'u')
// → SELECT u.id, u.name, (SELECT COUNT(*) FROM posts p WHERE p.user_id = u.id) AS `post_count`WHERE IN-Subquery
$activeUserIds = (new DbQuery())
->select('user_id')
->from('orders')
->where('status')->equals('completed');
(new DbQuery())
->select('*')
->from('users')
->where('id')->in($activeUserIds)
// → WHERE id IN (SELECT user_id FROM orders WHERE status = ?)CTEs (Common Table Expressions)
CTEs machen komplexe Queries lesbar, indem sie benannte Zwischenergebnisse definieren.
Einfache CTE
$activeUsers = (new DbQuery())
->select('id, name, dept')
->from('users')
->where('status')->equals('active');
(new DbQuery())
->with('active', $activeUsers)
->select('dept, COUNT(*) as cnt')
->from('active')
->groupBy('dept')
->sql('mysql', prepared: true);
// → WITH `active` AS (SELECT id, name, dept FROM `users` WHERE status = ?)
// SELECT dept, COUNT(*) as cnt FROM `active` GROUP BY deptMehrere CTEs
->with('cte1', $query1)
->with('cte2', $query2) // kann cte1 referenzieren
->select('*')
->from('cte2')Rekursive CTEs
Ideal für Baumstrukturen (Kategorien, Organigramme, Stücklisten):
$recursive = (new DbQuery())
->select('id, parent_id, name, 0 as level')
->from('categories')
->where('parent_id')->isNull()
->union(
(new DbQuery())
->select('c.id, c.parent_id, c.name, tree.level + 1')
->from('categories', 'c')
->innerJoin('category_tree', 'c.parent_id = tree.id', 'tree')
);
(new DbQuery())
->withRecursive('category_tree', $recursive)
->select('*')
->from('category_tree')
->orderBy('level')Binding-Reihenfolge
CTE-Bindings werden vor den Bindings der Hauptquery gemergt. Das ist automatisch korrekt. Keine manuelle Sortierung nötig.
Window Functions
Für Ranking, Running Totals, Vergleiche mit vorherigen/nächsten Zeilen, ohne GROUP BY.
Inline Window
(new DbQuery())
->select('id, name, salary')
->selectWindow('ROW_NUMBER', 'rank')
->partitionBy('department')
->windowOrderBy('salary', 'DESC')
->endWindow()
->from('employees')Erzeugt: ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rank
Mit Argumenten
// SUM mit Feld
->selectWindow('SUM', 'running_total', 'amount')
->windowOrderBy('date')
->endWindow()
// → SUM(amount) OVER (ORDER BY date ASC) AS `running_total`
// LAG mit mehreren Argumenten
->selectWindow('LAG', 'prev_price', 'price, 1')
->windowOrderBy('date')
->endWindow()
// → LAG(price, 1) OVER (ORDER BY date ASC) AS `prev_price`Frame-Spezifikation
->selectWindow('SUM', 'moving_avg', 'price')
->windowOrderBy('date')
->frame('ROWS', '2 PRECEDING', 'CURRENT ROW')
->endWindow()
// → SUM(price) OVER (ORDER BY date ASC ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)Named Windows
Wenn mehrere Funktionen dasselbe Window teilen:
(new DbQuery())
->select('id, name, salary')
->window('dept_window')
->partitionBy('department')
->windowOrderBy('salary', 'DESC')
->endWindow()
->selectWindowRef('ROW_NUMBER', 'dept_window', 'rank')
->selectWindowRef('DENSE_RANK', 'dept_window', 'dense_rank')
->from('employees')
// → ... WINDOW dept_window AS (PARTITION BY department ORDER BY salary DESC)JSON-Conditions
Für Queries auf JSON/JSONB-Spalten, automatisch dialect-spezifisch übersetzt.
JSON-Wert extrahieren
->whereJson('metadata')->extract('$.priority')->equals('high')
->andJson('settings')->extract('$.theme.color')->notEquals('red')| Dialect | Erzeugtes SQL |
|---|---|
| MySQL | JSON_EXTRACT(metadata, '$.priority') = ? |
| PostgreSQL | metadata->>'priority' = ? |
| SQLite | JSON_EXTRACT(metadata, '$.priority') = ? |
JSON containment
->whereJson('tags')->contains('php')
->andJson('tags')->notContains('deprecated')
->whereJson('data')->contains('value', '$.nested.path') // mit PfadJSON Array-Länge
->whereJson('items')->length()->greater(3)
->whereJson('data')->length('$.nested.array')->equals(0)HAVING mit JSON
->groupBy('user_id')
->havingJson('metadata')->extract('$.priority')->equals('high')INSERT — Erweiterte Features
Multi-Row Insert
(new DbInsert())
->into('products')
->fields('sku', 'name', 'price')
->values('A-001', 'Widget', 9.99)
->values('A-002', 'Gadget', 19.99)
->values('A-003', 'Gizmo', 29.99)INSERT mit set() (assoziativ)
(new DbInsert())
->into('users')
->set(['name' => 'Anna', 'email' => 'anna@example.com', 'status' => 'active'])INSERT...SELECT
$activeUsers = (new DbQuery())
->select('name, email')
->from('users')
->where('status')->equals('active');
(new DbInsert())
->into('newsletter_subscribers')
->fields('name', 'email')
->fromSelect($activeUsers)Upsert (Conflict Handling)
MySQL / MariaDB:
->onDuplicateKeyUpdate('price', Expression::raw('VALUES(price)'))
->onDuplicateKeyUpdate('stock', 42)PostgreSQL:
->onConflict('sku')
->doUpdate(['price' => 8.99, 'name' => 'Updated Widget'])
// oder:
->onConflict('sku')
->doNothing()SQLite:
->orIgnore() // INSERT OR IGNORE INTO
->replace() // REPLACE INTOUPDATE — Erweiterte Features
SET mit Subquery
(new DbUpdate())
->table('products')
->set('avg_rating', (new DbQuery())
->select('AVG(rating)')
->from('reviews', 'r')
->where('r.product_id')->equals(Expression::raw('products.id'))
)UPDATE mit JOIN (nur MySQL)
(new DbUpdate())
->table('users', 'u')
->innerJoin('orders o', 'u.id = o.user_id')
->set('u.status', 'premium')
->where('o.total')->greater(1000)UPDATE IGNORE (nur MySQL)
(new DbUpdate())
->table('users')
->ignore()
->set('email', 'new@example.com')
->where('id')->equals(42)DELETE — Erweiterte Features
DELETE mit JOIN (nur MySQL)
(new DbDelete())
->from('users', 'u')
->innerJoin('banned_emails b', 'u.email = b.email')
->where('u.created_at')->lower('2020-01-01')GROUP BY, ORDER BY, LIMIT
// GROUP BY (variadic)
->groupBy('department', 'status')
// HAVING (nur SELECT)
->having('COUNT(*)')->greater(5)
// ORDER BY (mehrfach aufrufbar)
->orderBy('name') // ASC ist Standard
->orderBy('created_at', 'DESC')
// LIMIT + OFFSET
->limit(10) // LIMIT 10
->limit(10, 20) // LIMIT 10 OFFSET 20UNION
$active = (new DbQuery())->select('id, name')->from('users')->where('status')->equals('active');
$admins = (new DbQuery())->select('id, name')->from('admins');
// Dedupliziert
$active->union($admins)
// Alle Zeilen
$active->unionAll($admins)Dialekt-Unterschiede
Nicht alle SQL-Features sind in allen Datenbanken verfügbar. Der Builder wirft InvalidArgumentException wenn ein Feature im gewählten Dialekt nicht unterstützt wird.
| Feature | MySQL/MariaDB | PostgreSQL | SQLite |
|---|---|---|---|
| FULL OUTER JOIN | — | ✓ | — |
| UPDATE/DELETE mit JOIN | ✓ | — | — |
| UPDATE/DELETE ORDER BY + LIMIT | ✓ | — | — |
| UPDATE IGNORE | ✓ | — | — |
| ON DUPLICATE KEY UPDATE | ✓ | — | — |
| ON CONFLICT DO UPDATE/NOTHING | — | ✓ | ✓ |
| REPLACE INTO | ✓ | — | ✓ |
| Window Functions | ✓ | ✓ | ✓ |
| CTEs (WITH) | ✓ | ✓ | ✓ |
| JSON-Conditions | ✓ | ✓ | ✓ |
| Boolean-Literale | 1/0 | TRUE/FALSE | 1/0 |
| Identifier-Quoting | `backtick` | "double" | `backtick` |
Architektur
Das Package folgt dem Closure-Orchestrator-Pattern mit einer klaren Trennung zwischen Builder (Fluent API), State (Daten) und SQL-Generierung:
DbQuery / DbInsert / DbUpdate / DbDelete ← Fluent API (Einstiegspunkte)
├── QueryState / InsertState / ... ← State-Objekte (Daten sammeln)
├── QueryCondition / QueryJsonCondition ← Condition Builder (WHERE/HAVING)
└── SqlBuilderFactory ← Erzeugt dialect-spezifische SQL-Builder
├── MySql / PostgresSql / SqliteSql ← SELECT SQL-Generierung
├── InsertMySql / InsertPostgresSql / ...← INSERT SQL-Generierung
├── UpdateMySql / UpdatePostgresSql / ...← UPDATE SQL-Generierung
└── DeleteMySql / DeletePostgresSql / ...← DELETE SQL-GenerierungVerzeichnisstruktur
src/
├── DbQuery.php ← SELECT Builder
├── DbInsert.php ← INSERT Builder
├── DbUpdate.php ← UPDATE Builder
├── DbDelete.php ← DELETE Builder
├── Command/
│ ├── Delete/ ← DELETE SQL-Generierung
│ │ ├── DeleteMySql.php
│ │ ├── DeletePostgresSql.php
│ │ └── DeleteSqliteSql.php
│ ├── Insert/ ← INSERT SQL-Generierung
│ │ ├── InsertMySql.php
│ │ ├── InsertPostgresSql.php
│ │ ├── InsertSqliteSql.php
│ │ └── Method/ ← INSERT-Erweiterungen (Upsert etc.)
│ └── Update/ ← UPDATE SQL-Generierung
│ ├── UpdateMySql.php
│ ├── UpdatePostgresSql.php
│ ├── UpdateSqliteSql.php
│ └── Method/
├── Data/
│ ├── Contract/ ← Interne State-Interfaces
│ │ ├── FromStateInterface.php
│ │ ├── JoinStateInterface.php
│ │ ├── LimitStateInterface.php
│ │ └── OrderByStateInterface.php
│ ├── Dialect.php ← Enum: mysql, mariadb, postgres, sqlite
│ ├── Expression.php ← Raw SQL Expressions
│ ├── DbPreparedQuery.php ← Prepared Statement (SQL + Bindings)
│ ├── QueryResult.php ← SELECT-Ergebnis
│ ├── ExecuteResult.php ← INSERT/UPDATE/DELETE-Ergebnis
│ ├── QueryState.php ← SELECT State
│ ├── InsertState.php ← INSERT State
│ ├── UpdateState.php ← UPDATE State
│ ├── DeleteState.php ← DELETE State
│ ├── WindowSpec.php ← Window-Definition
│ ├── WindowFunction.php ← Inline Window Function
│ └── WindowReference.php ← Named Window Reference
├── Query/
│ ├── SqlBuilder.php ← Basis SQL-Generierung
│ ├── MySql.php ← MySQL-spezifisch
│ ├── PostgresSql.php ← PostgreSQL-spezifisch
│ ├── Condition/
│ │ ├── QueryCondition.php ← Standard-Operatoren
│ │ └── QueryJsonCondition.php ← JSON-Operatoren
│ └── Builder/ ← Clause-Builder (Invokables)
│ ├── Clause/ ← SQL-Klausel-Builder (SELECT, FROM, JOIN, ...)
│ ├── Condition/ ← Bedingungsauflösung und Validierung
│ ├── Method/ ← Fluent-API-Methoden (Where, OrderBy, ...)
│ └── Window/ ← Window-Function-Builder
└── Factory/
├── SqlBuilderFactory.php ← Dialect-Dispatch
└── BuilderRegistry.php ← Singleton-Cache + Version-OverridesAPI-Referenz
DbPreparedQuery
| Methode | Return | Beschreibung |
|---|---|---|
sql() | string | SQL mit ?-Platzhaltern |
bindings() | array | Parameter-Werte in korrekter Reihenfolge |
type() | string | Dialect ('mysql', 'postgres', ...) |
Dialect (Enum)
| Methode | Beschreibung |
|---|---|
Dialect::fromString('mysql') | String → Enum (wirft bei ungültigem Wert) |
Dialect::tryFromString('mysql') | String → Enum oder null |
->defaultVersion() | Standard-Version ('8.0', '14', ...) |
->supportedVersions() | Alle unterstützten Versionen |
QueryResult / ExecuteResult
// SELECT-Ergebnis
$result->fetchAll(); // array<int, array<string, mixed>>
$result->fetchOne(); // ?array<string, mixed>
$result->rowCount(); // int
// INSERT/UPDATE/DELETE-Ergebnis
$result->affectedRows(); // int
$result->lastInsertId(); // string|falseVollständiges Beispiel
Ein realistisches Reporting-Query mit CTEs, Window Functions, Subqueries und JSON:
use JardisSupport\DbQuery\DbQuery;
use JardisSupport\DbQuery\Data\Expression;
// CTE: Monatsumsätze pro Kunde
$monthlySales = (new DbQuery())
->select('customer_id, DATE_FORMAT(order_date, "%Y-%m") as month, SUM(total) as revenue')
->from('orders')
->where('status')->equals('completed')
->and('order_date')->greaterEquals('2024-01-01')
->groupBy('customer_id', 'DATE_FORMAT(order_date, "%Y-%m")');
// Hauptquery mit Window Function
$prepared = (new DbQuery())
->with('monthly', $monthlySales)
->select('m.customer_id, c.name, m.month, m.revenue')
->selectWindow('SUM', 'running_total', 'm.revenue')
->partitionBy('m.customer_id')
->windowOrderBy('m.month')
->endWindow()
->selectWindow('LAG', 'prev_month_revenue', 'm.revenue, 1')
->partitionBy('m.customer_id')
->windowOrderBy('m.month')
->endWindow()
->from('monthly', 'm')
->innerJoin('customers c', 'm.customer_id = c.id')
->whereJson('c.settings')->extract('$.tier')->in(['gold', 'platinum'])
->and('m.revenue')->greater(1000)
->orderBy('m.customer_id')
->orderBy('m.month')
->sql('mysql', prepared: true);
$stmt = $pdo->prepare($prepared->sql());
$stmt->execute($prepared->bindings());