Skip to content

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

bash
composer require jardissupport/dbquery

GitHub: jardisSupport/dbquery

Grundlegende Nutzung

SELECT

php
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

php
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

php
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

php
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:

php
->sql(string $dialect, bool $prepared = true, ?string $version = null)
ParameterWerteBeschreibung
$dialect'mysql', 'mariadb', 'postgres', 'sqlite'Ziel-Datenbank
$preparedtrue (Standard)trueDbPreparedQuery, false → SQL-String (Debugging)
$versionnull (Standard)Datenbankversion (z.B. '8.4' für MySQL)

Prepared Statements (Produktion)

php
$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)

php
$raw = $query->sql('mysql', prepared: false);
// "SELECT * FROM `users` WHERE status = 'active'"

WHERE-Bedingungen

Vergleichsoperatoren

php
->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

php
->where('deleted_at')->isNull()         // WHERE deleted_at IS NULL
->where('email')->isNotNull()          // WHERE email IS NOT NULL

LIKE

php
->where('name')->like('%anna%')         // WHERE name LIKE ?
->where('email')->notLike('%test%')    // WHERE email NOT LIKE ?

IN / NOT IN

php
->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

php
->where('age')->between(18, 65)         // WHERE age BETWEEN ? AND ?
->where('age')->notBetween(18, 65)     // WHERE age NOT BETWEEN ? AND ?

AND / OR Chaining

php
->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:

php
// 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

php
$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:

php
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

php
->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 ON

Subquery-JOINs

php
$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-TypSELECTUPDATEDELETE
INNER JOINAlleNur MySQLNur MySQL
LEFT JOINAlleNur MySQLNur MySQL
RIGHT JOINAlle
FULL OUTER JOINNur PostgreSQL
CROSS JOINAlle

Subqueries

FROM-Subquery

php
$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)

php
$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

php
$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

php
$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 dept

Mehrere CTEs

php
->with('cte1', $query1)
->with('cte2', $query2)    // kann cte1 referenzieren
->select('*')
->from('cte2')

Rekursive CTEs

Ideal für Baumstrukturen (Kategorien, Organigramme, Stücklisten):

php
$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

php
(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

php
// 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

php
->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:

php
(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

php
->whereJson('metadata')->extract('$.priority')->equals('high')
->andJson('settings')->extract('$.theme.color')->notEquals('red')
DialectErzeugtes SQL
MySQLJSON_EXTRACT(metadata, '$.priority') = ?
PostgreSQLmetadata->>'priority' = ?
SQLiteJSON_EXTRACT(metadata, '$.priority') = ?

JSON containment

php
->whereJson('tags')->contains('php')
->andJson('tags')->notContains('deprecated')
->whereJson('data')->contains('value', '$.nested.path')  // mit Pfad

JSON Array-Länge

php
->whereJson('items')->length()->greater(3)
->whereJson('data')->length('$.nested.array')->equals(0)

HAVING mit JSON

php
->groupBy('user_id')
->havingJson('metadata')->extract('$.priority')->equals('high')

INSERT — Erweiterte Features

Multi-Row Insert

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

php
(new DbInsert())
    ->into('users')
    ->set(['name' => 'Anna', 'email' => 'anna@example.com', 'status' => 'active'])

INSERT...SELECT

php
$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:

php
->onDuplicateKeyUpdate('price', Expression::raw('VALUES(price)'))
->onDuplicateKeyUpdate('stock', 42)

PostgreSQL:

php
->onConflict('sku')
->doUpdate(['price' => 8.99, 'name' => 'Updated Widget'])
// oder:
->onConflict('sku')
->doNothing()

SQLite:

php
->orIgnore()   // INSERT OR IGNORE INTO
->replace()    // REPLACE INTO

UPDATE — Erweiterte Features

SET mit Subquery

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

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

php
(new DbUpdate())
    ->table('users')
    ->ignore()
    ->set('email', 'new@example.com')
    ->where('id')->equals(42)

DELETE — Erweiterte Features

DELETE mit JOIN (nur MySQL)

php
(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

php
// 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 20

UNION

php
$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.

FeatureMySQL/MariaDBPostgreSQLSQLite
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-Literale1/0TRUE/FALSE1/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-Generierung

Verzeichnisstruktur

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-Overrides

API-Referenz

DbPreparedQuery

MethodeReturnBeschreibung
sql()stringSQL mit ?-Platzhaltern
bindings()arrayParameter-Werte in korrekter Reihenfolge
type()stringDialect ('mysql', 'postgres', ...)

Dialect (Enum)

MethodeBeschreibung
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

php
// 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|false

Vollständiges Beispiel

Ein realistisches Reporting-Query mit CTEs, Window Functions, Subqueries und JSON:

php
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());