From 551d77a196fea984d28de8583a063f5b6488462d Mon Sep 17 00:00:00 2001 From: Mike W <3036663+enlivenapp@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:56:56 -0400 Subject: [PATCH] Add query builder extensions: aggregates, subqueries, IN/LIKE helpers, groups, HAVING, unions, batch writes, BC compat preserved. Added: - selectSum/selectAvg/selectMin/selectMax/selectCount with optional alias - distinct() - selectSubquery()/fromSubquery() with correctly ordered parameters - whereIn/orWhereIn/whereNotIn/orWhereNotIn (array or subquery Builder) - like/orLike/notLike/orNotLike (value bound as LIKE ?, wildcards escaped, explicit ESCAPE '!', positions both/before/after/none) - groupStart/orGroupStart/notGroupStart/groupEnd with unbalanced-group error - having()/orHaving() - rightJoin() - union()/unionAll() - insertBatch()/upsertBatch()/updateBatch()/deleteBatch() - when()/whenNot() - build(bool $reset = false) opt-in reset - orderBy($column, ?string $direction = null) validated second form (safeIdentifier + ASC/DESC) - tests/BuilderExtensionsTest.php, tests/QueryLoggerTest.php, tests/QueryPanelTest.php, BuilderRaw::__toString coverage Fixed: - count() no longer leaks select-subquery params into COUNT (placeholder/param mismatch) - count() emits JOIN clauses again (restore original behavior) - delete() no longer uses fromSubquery (was emitting invalid DELETE FROM (subquery) and dropping params) - QueryLogger metric-key guard so new batch actions don't warn on unknown keys - updateBatch() missing-WHERE-column test now exercises the intended check Changed: - WHERE conditions stored as structured {join, sql} parts to support groups and HAVING - buildSQL() now shares compileSelect() with build() - clearAll() clears the new builder state (selectParams/fromParams/having/unions/distinct/fromSubquery/batch) See CHANGELOG.md and README.md for more details. --- CHANGELOG.md | 19 + README.md | 362 +++++++- src/Builder.php | 1247 +++++++++++++++++++++---- src/QueryLogger.php | 11 +- src/QueryPanel.php | 6 + tests/BuilderExtensionsTest.php | 1537 +++++++++++++++++++++++++++++++ tests/BuilderRawTest.php | 14 + tests/QueryLoggerTest.php | 89 ++ tests/QueryPanelTest.php | 86 ++ 9 files changed, 3207 insertions(+), 164 deletions(-) create mode 100644 tests/BuilderExtensionsTest.php create mode 100644 tests/QueryLoggerTest.php create mode 100644 tests/QueryPanelTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 57fa010..24636c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Aggregate functions**: `selectSum()`, `selectAvg()`, `selectMin()`, `selectMax()`, `selectCount()` with optional column and alias +- **Distinct**: `distinct()` adds `DISTINCT` to SELECT queries +- **Subqueries**: `selectSubquery(Builder $query, string $alias)` and `fromSubquery(Builder $query, string $alias)` with correctly ordered parameters +- **IN conditions**: `whereIn()`, `orWhereIn()`, `whereNotIn()`, `orWhereNotIn()` accepting arrays or subquery Builders +- **LIKE conditions**: `like()`, `orLike()`, `notLike()`, `orNotLike()` with position helpers (`'before'`, `'after'`, `'both'`, `'none'`). Values are bound and escaped with an explicit `ESCAPE '!'` clause for `NO_BACKSLASH_ESCAPES` compatibility +- **Condition grouping**: `groupStart()`, `orGroupStart()`, `notGroupStart()`, `groupEnd()` with a helpful error on unbalanced groups +- **HAVING conditions**: `having()` and `orHaving()` for filtered aggregate queries +- **RIGHT JOIN**: `rightJoin()` convenience method +- **UNION**: `union(Builder $query)` and `unionAll(Builder $query)` +- **Batch writes**: `insertBatch(array $rows)`, `upsertBatch(array $rows, array $uniqueKeys)`, `updateBatch(array $rows, string $whereColumn)`, `deleteBatch(string $whereColumn, array $values)` +- **Conditional chaining**: `when($condition, callable $callback)` and `whenNot($condition, callable $callback)` +- **Query building options**: `build(bool $reset = false)` resets the builder when `$reset` is true, clearing conditions and resetting the action to `SELECT`; `orderBy(string $column, ?string $direction = null)` accepts either a full sort expression or a validated `ASC`/`DESC` direction +- Test coverage for all new features + +### Fixed +- PHPStan level-max compliance in `QueryLogger` and `QueryPanel` (documented array shapes) +- Undefined array key warnings for batch query metrics in `QueryLogger` + ## [1.0.2.3] - 2026-03-18 ### Added diff --git a/README.md b/README.md index 4f8da84..90b2dba 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,10 @@ A lightweight, fluent PHP SQL query builder that generates SQL and parameters. D - 🔧 **Raw SQL Support** - Insert raw SQL expressions with `raw()` - 📝 **Multiple Query Types** - SELECT, INSERT, UPDATE, DELETE, COUNT - 🔀 **JOIN Support** - INNER, LEFT, RIGHT joins with aliases -- ðŸŽŊ **Advanced Conditions** - LIKE, IN, BETWEEN, comparison operators +- ðŸŽŊ **Advanced Conditions** - LIKE, IN, BETWEEN, comparison operators, grouped conditions +- 📊 **Aggregates** - COUNT, SUM, AVG, MIN, MAX with DISTINCT and subqueries +- ðŸ§Ū **Batch Writes** - insertBatch, updateBatch, upsertBatch, deleteBatch +- 🊄 **Conditional Chaining** - `when()` / `whenNot()` for readable dynamic queries - 🌐 **Database Agnostic** - Returns SQL + params, use with any DB connection - ðŸŠķ **Lightweight** - Minimal footprint with zero required dependencies @@ -334,6 +337,257 @@ $q = Builder::table('users') // params: ['active', 'admin', 'moderator', '%manage%'] ``` +#### IN / NOT IN Methods + +```php +// IN with an array of values +$q = Builder::table('users') + ->where(['status' => 'active']) + ->whereIn('id', [1, 2, 3, 4, 5]) + ->build(); +// sql: "SELECT * FROM users WHERE status = ? AND id IN (?, ?, ?, ?, ?)" +// params: ['active', 1, 2, 3, 4, 5] + +// IN with a subquery +$q = Builder::table('users') + ->whereIn('id', Builder::table('logs')->select('user_id')->where(['type' => 'signup'])) + ->build(); +// sql: "SELECT * FROM users WHERE id IN (SELECT user_id FROM logs WHERE type = ?)" +// params: ['signup'] + +// NOT IN +$q = Builder::table('users') + ->whereNotIn('status', ['banned', 'deleted']) + ->build(); +// sql: "SELECT * FROM users WHERE status NOT IN (?, ?)" +// params: ['banned', 'deleted'] +``` + +#### LIKE / NOT LIKE + +```php +// LIKE with escaped wildcards and an explicit ESCAPE clause +$q = Builder::table('products') + ->where(['category' => 'books']) + ->like('title', '100%') + ->build(); +// sql: "SELECT * FROM products WHERE category = ? AND title LIKE ? ESCAPE '!'" +// params: ['books', '%100!%%'] + +// Search only the start of the value +$q = Builder::table('products') + ->like('title', 'PHP', 'after') + ->build(); +// sql: "SELECT * FROM products WHERE title LIKE ? ESCAPE '!'" +// params: ['PHP%'] + +// NOT LIKE +$q = Builder::table('products') + ->notLike('title', 'expired') + ->build(); +// sql: "SELECT * FROM products WHERE title NOT LIKE ? ESCAPE '!'" +// params: ['%expired%'] +``` + +#### Grouped Conditions + +```php +// AND group (nested parentheses) +$q = Builder::table('orders') + ->where(['status' => 'open']) + ->groupStart() + ->where(['total' => ['>=', 100]]) + ->where(['priority' => 'high']) + ->groupEnd() + ->build(); +// sql: "SELECT * FROM orders WHERE status = ? AND (total >= ? AND priority = ?)" +// params: ['open', 100, 'high'] + +// OR alternation via orWhere() (conditions within the same call are joined with OR) +$q = Builder::table('orders') + ->where(['status' => 'open']) + ->where(['total' => ['>=', 100]]) + ->orWhere(['priority' => 'high', 'urgent' => 1]) + ->build(); +// sql: "SELECT * FROM orders WHERE status = ? AND total >= ? AND (priority = ? OR urgent = ?)" +// params: ['open', 100, 'high', 1] + +// OR group via orGroupStart() +$q = Builder::table('users') + ->where(['status' => 'active']) + ->orGroupStart() + ->where(['role' => 'admin']) + ->where(['plan' => 'premium']) + ->groupEnd() + ->build(); +// sql: "SELECT * FROM users WHERE status = ? OR (role = ? AND plan = ?)" +// params: ['active', 'admin', 'premium'] + +// Negated group +$q = Builder::table('users') + ->where(['status' => 'active']) + ->notGroupStart() + ->where(['role' => 'guest']) + ->where(['banned' => 1]) + ->groupEnd() + ->build(); +// sql: "SELECT * FROM users WHERE status = ? AND NOT (role = ? AND banned = ?)" +// params: ['active', 'guest', 1] +``` + +### HAVING + +```php +$q = Builder::table('orders') + ->select(['user_id', 'total']) + ->groupBy('user_id') + ->having(['total' => ['>', 1000]]) + ->build(); +// sql: "SELECT user_id, total FROM orders GROUP BY user_id HAVING total > ?" +// params: [1000] +``` + +### Aggregate Functions + +```php +$q = Builder::table('orders') + ->selectCount('*', 'total_orders') + ->selectSum('amount', 'total_amount') + ->where(['status' => 'paid']) + ->build(); +// sql: "SELECT COUNT(*) AS total_orders, SUM(amount) AS total_amount FROM orders WHERE status = ?" +// params: ['paid'] + +$q = Builder::table('orders') + ->select(['user_id']) + ->selectAvg('amount') + ->selectMin('amount', 'min_amount') + ->selectMax('amount', 'max_amount') + ->groupBy('user_id') + ->build(); +// sql: "SELECT user_id, AVG(amount), MIN(amount) AS min_amount, MAX(amount) AS max_amount FROM orders GROUP BY user_id" +// params: [] +``` + +### DISTINCT + +```php +$q = Builder::table('users') + ->distinct() + ->select(['country']) + ->build(); +// sql: "SELECT DISTINCT country FROM users" +// params: [] +``` + +### Subqueries + +```php +// Subquery in the SELECT list +$q = Builder::table('users') + ->select(['id', 'name']) + ->selectSubquery( + Builder::table('orders')->selectCount('*')->where(['user_id' => 42]), + 'order_count' + ) + ->build(); +// sql: "SELECT id, name, (SELECT COUNT(*) FROM orders WHERE user_id = ?) AS order_count FROM users" +// params: [42] + +// Subquery as the FROM source +$q = Builder::table('users') + ->fromSubquery( + Builder::table('users')->select(['id', 'email'])->where(['status' => 'active']), + 'u' + ) + ->where(['u.age' => ['>=', 18]]) + ->build(); +// sql: "SELECT * FROM (SELECT id, email FROM users WHERE status = ?) AS u WHERE u.age >= ?" +// params: ['active', 18] +``` + +### UNION + +```php +$q = Builder::table('active_users') + ->select(['id', 'name']) + ->union(Builder::table('vip_users')->select(['id', 'name'])) + ->build(); +// sql: "SELECT id, name FROM active_users UNION SELECT id, name FROM vip_users" +// params: [] + +// UNION ALL keeps duplicate rows +$q = Builder::table('jan_orders') + ->unionAll(Builder::table('feb_orders')) + ->build(); +// sql: "SELECT * FROM jan_orders UNION ALL SELECT * FROM feb_orders" +// params: [] +``` + +### Batch Writes + +```php +// Multi-row INSERT +$q = Builder::table('users') + ->insertBatch([ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Bob', 'email' => 'bob@example.com'], + ]) + ->build(); +// sql: "INSERT INTO users (name, email) VALUES (?, ?), (?, ?)" +// params: ['Alice', 'alice@example.com', 'Bob', 'bob@example.com'] + +// Upsert with ON DUPLICATE KEY UPDATE +$q = Builder::table('user_stats') + ->upsertBatch([ + ['user_id' => 1, 'views' => 10], + ['user_id' => 2, 'views' => 5], + ], ['user_id']) + ->build(); +// sql: "INSERT INTO user_stats (user_id, views) VALUES (?, ?), (?, ?) ON DUPLICATE KEY UPDATE views = VALUES(views)" +// params: [1, 10, 2, 5] + +// Multi-row UPDATE via CASE WHEN +$q = Builder::table('users') + ->updateBatch([ + ['id' => 1, 'status' => 'active'], + ['id' => 2, 'status' => 'disabled'], + ], 'id') + ->build(); +// sql: "UPDATE users SET status = CASE WHEN id = ? THEN ? WHEN id = ? THEN ? END WHERE id IN (?, ?)" +// params: [1, 'active', 2, 'disabled', 1, 2] + +// Batch DELETE +$q = Builder::table('users') + ->deleteBatch('id', [1, 2, 3]) + ->build(); +// sql: "DELETE FROM users WHERE id IN (?, ?, ?)" +// params: [1, 2, 3] +``` + +### Conditional Chaining + +```php +$q = Builder::table('products') + ->when(!empty($categoryId), function ($query) use ($categoryId) { + $query->where(['category_id' => $categoryId]); + }) + ->when(!empty($searchTerm), function ($query) use ($searchTerm) { + $query->like('name', $searchTerm); + }) + ->build(); +``` + +### Builder Reuse with build(true) + +```php +// Reset a builder in one call (replaces manual clear*() calls) +$q = Builder::table('users') + ->where(['status' => 'active']) + ->build(true); +// $q is the query result; the builder is reset and ready for the next statement +``` + ### INSERT Queries ```php @@ -738,9 +992,108 @@ Add GROUP BY clause. #### `orderBy(string $orderBy): self` Add ORDER BY clause. +#### `orderBy(string $column, ?string $direction = null): self` +Add ORDER BY. Without a direction, `$orderBy` is treated as a full sort expression (e.g. `created_at DESC`). With a direction, the column is validated with `safeIdentifier()` and the direction must be `ASC` or `DESC`, making it safe for user input. + #### `limit(int $limit, int $offset = 0): self` Add LIMIT and optional OFFSET. +#### `distinct(): self` +Add `DISTINCT` to the SELECT query. + +#### `selectSubquery(Builder $query, string $alias): self` +Add a subquery as a SELECT column with an alias. + +#### `selectCount(string $column, string $alias = ''): self` +Add `COUNT(column)` to the SELECT list. + +#### `selectSum(string $column, string $alias = ''): self` +Add `SUM(column)` to the SELECT list. + +#### `selectAvg(string $column, string $alias = ''): self` +Add `AVG(column)` to the SELECT list. + +#### `selectMin(string $column, string $alias = ''): self` +Add `MIN(column)` to the SELECT list. + +#### `selectMax(string $column, string $alias = ''): self` +Add `MAX(column)` to the SELECT list. + +#### `fromSubquery(Builder $query, string $alias): self` +Use a subquery as the FROM source instead of the table. + +#### `whereIn(string $column, array $values): self` +Add a `WHERE column IN (...)` condition. + +#### `whereIn(string $column, Builder $query): self` +Add a `WHERE column IN (subquery)` condition with correctly ordered parameters. + +#### `orWhereIn(string $column, array|Builder $values): self` +Add an OR `IN` condition (array or subquery). + +#### `whereNotIn(string $column, array|Builder $values): self` +Add a `WHERE column NOT IN (...)` condition (array or subquery). + +#### `orWhereNotIn(string $column, array|Builder $values): self` +Add an OR `NOT IN` condition (array or subquery). + +#### `like(string $column, string $value, string $position = 'both'): self` +Add a `WHERE column LIKE ?` condition. Values are bound as parameters and escaped with an explicit `ESCAPE '!'` clause. `$position` is one of `'before'`, `'after'`, `'both'`, `'none'`. + +#### `orLike(string $column, string $value, string $position = 'both'): self` +Add an OR `LIKE` condition. + +#### `notLike(string $column, string $value, string $position = 'both'): self` +Add a `WHERE column NOT LIKE ?` condition. + +#### `orNotLike(string $column, string $value, string $position = 'both'): self` +Add an OR `NOT LIKE` condition. + +#### `groupStart(): self` +Open a new condition group joined with AND. Must be closed with `groupEnd()`. + +#### `orGroupStart(): self` +Open a new condition group joined with OR. Must be closed with `groupEnd()`. + +#### `notGroupStart(): self` +Open a negated condition group (`NOT (...)`). Must be closed with `groupEnd()`. + +#### `groupEnd(): self` +Close an open condition group. + +#### `having(array $conditions): self` +Add HAVING conditions for filtered aggregate queries. + +#### `orHaving(array $conditions): self` +Add OR HAVING conditions. + +#### `rightJoin(string $table, string $condition, string $alias = ''): self` +Add a RIGHT JOIN clause. + +#### `union(Builder $query): self` +Add a UNION to the query. + +#### `unionAll(Builder $query): self` +Add a UNION ALL to the query. + +#### `insertBatch(array $rows): self` +Set the query action to a multi-row INSERT. All rows must contain the same columns. Raw `Builder::raw()` values are inlined. + +#### `upsertBatch(array $rows, array $uniqueKeys): self` +Set the query action to INSERT ... ON DUPLICATE KEY UPDATE (MySQL/MariaDB). `$uniqueKeys` are the columns that define a duplicate row; remaining columns are updated with `VALUES(column)`. + +#### `updateBatch(array $rows, string $whereColumn): self` +Set the query action to a multi-row UPDATE using CASE WHEN blocks. The `$whereColumn` value identifies each row and is also used in the final `WHERE ... IN (...)` clause. + +#### `deleteBatch(string $whereColumn, array $values): self` +Set the query action to DELETE WHERE column IN (...). + +#### `when($condition, callable $callback): self` +Conditionally apply query modifications. If `$condition` is truthy, `$callback($this)` is invoked. Always returns `$this` and leaves the builder usable. + +#### `whenNot($condition, callable $callback): self` +Inverse of `when()`. The callback runs when the condition is falsy. + #### `count(string $column = '*'): self` Set the query action to COUNT. @@ -774,8 +1127,8 @@ Clear LIMIT and OFFSET. #### `clearAll(): self` Clear all query conditions (reset builder to initial state). -#### `build(): array` -Build and return the query as `['sql' => string, 'params' => array]`. +#### `build(bool $reset = false): array` +Build and return the query as `['sql' => string, 'params' => array]`. Pass `true` to reset the builder afterwards (opt-in; the builder is not reset by default). The reset clears the conditions and returns the action to `SELECT`, so the builder can be reused for any query type; the table and alias are preserved. Note this differs from `clearAll()`, which is called during the reset but deliberately preserves the action (existing behavior); `build(true)` resets it as an extra step. #### `get(): array` Alias for `build()`. @@ -783,6 +1136,9 @@ Alias for `build()`. #### `buildSQL(): string` Build and return only the SQL string (for SELECT queries). +#### `getSQL(): string` +Alias for `buildSQL()`. + #### `getParams(): array` Get the parameter array for binding. diff --git a/src/Builder.php b/src/Builder.php index 4b8e6a4..0884c14 100644 --- a/src/Builder.php +++ b/src/Builder.php @@ -18,10 +18,18 @@ class Builder { private string $select = '*'; /** @var array */ private array $joins = []; - /** @var array */ + /** @var array */ private array $where = []; /** @var array */ private array $params = []; + /** @var array */ + private array $selectParams = []; + /** @var array */ + private array $fromParams = []; + /** @var array */ + private array $having = []; + /** @var array */ + private array $havingParams = []; private string $groupBy = ''; private string $orderBy = ''; private int $limit = 0; @@ -31,6 +39,18 @@ class Builder { private string $countColumn = '*'; /** @var array */ private array $onDuplicateKeyUpdateData = []; + private bool $distinct = false; + private int $groupDepth = 0; + private string $fromSubquery = ''; + /** @var array */ + private array $unions = []; + /** @var array> */ + private array $batchRows = []; + private string $batchWhereColumn = ''; + /** @var array */ + private array $batchUniqueKeys = []; + /** @var array */ + private array $batchDeleteValues = []; /** * Constructor - Initialize Tracy logger if available @@ -135,7 +155,7 @@ public function alias(string $alias) : self { } /** - * Set the columns to select + * Set the columns to select (replaces any previously selected columns) * * @param string|array $columns Column names (default: '*') * @return self @@ -146,6 +166,158 @@ public function select($columns = '*') : self { } else { $this->select = $columns; } + $this->selectParams = []; + return $this; + } + + /** + * Add a SELECT DISTINCT flag to the query + * + * Prepends DISTINCT to the selected columns. + * + * @return self + * + * Example: + * Builder::table('users')->distinct()->select(['role'])->build(); + * // sql: SELECT DISTINCT role FROM users + */ + public function distinct() : self { + $this->distinct = true; + return $this; + } + + /** + * Add a SUM() aggregate to the SELECT list + * + * @param string $column Column to aggregate + * @param string $alias Optional alias for the aggregate result + * @return self + * + * Example: + * Builder::table('orders')->selectSum('total', 'total_sum')->build(); + * // sql: SELECT SUM(total) AS total_sum FROM orders + */ + public function selectSum(string $column, string $alias = '') : self { + return $this->appendAggregate('SUM', $column, $alias); + } + + /** + * Add an AVG() aggregate to the SELECT list + * + * @param string $column Column to aggregate + * @param string $alias Optional alias for the aggregate result + * @return self + */ + public function selectAvg(string $column, string $alias = '') : self { + return $this->appendAggregate('AVG', $column, $alias); + } + + /** + * Add a MIN() aggregate to the SELECT list + * + * @param string $column Column to aggregate + * @param string $alias Optional alias for the aggregate result + * @return self + */ + public function selectMin(string $column, string $alias = '') : self { + return $this->appendAggregate('MIN', $column, $alias); + } + + /** + * Add a MAX() aggregate to the SELECT list + * + * @param string $column Column to aggregate + * @param string $alias Optional alias for the aggregate result + * @return self + */ + public function selectMax(string $column, string $alias = '') : self { + return $this->appendAggregate('MAX', $column, $alias); + } + + /** + * Add a COUNT() aggregate to the SELECT list + * + * @param string $column Column to aggregate + * @param string $alias Optional alias for the aggregate result + * @return self + */ + public function selectCount(string $column, string $alias = '') : self { + return $this->appendAggregate('COUNT', $column, $alias); + } + + /** + * Add an aggregate expression to the SELECT list + * + * Appends to the current list of columns. If the default '*' is still set, + * it is replaced by the first aggregate. + * + * @param string $function Aggregate function name (SUM, AVG, MIN, MAX, COUNT) + * @param string $column Column to aggregate + * @param string $alias Optional alias + * @return self + */ + private function appendAggregate(string $function, string $column, string $alias) : self { + $expr = "{$function}({$column})"; + if ($alias !== '') { + $expr .= " AS {$alias}"; + } + $this->appendSelectPart($expr); + return $this; + } + + /** + * Add an expression to the SELECT list + * + * @param string $expr SELECT expression + * @return void + */ + private function appendSelectPart(string $expr) : void { + if ($this->select === '*') { + $this->select = $expr; + return; + } + $this->select .= ', ' . $expr; + } + + /** + * Embed a query builder result as a subquery in the SELECT list + * + * @param self $query Builder instance for the subquery + * @param string $alias Optional alias for the subquery result + * @return self + * + * Example: + * Builder::table('users') + * ->select(['id', 'name']) + * ->selectSubquery(Builder::table('orders')->selectCount('id'), 'order_count') + * ->build(); + */ + public function selectSubquery(self $query, string $alias = '') : self { + $compiled = $query->compileSelect(false); + $aliasSql = $alias === '' ? '' : " AS {$alias}"; + $this->appendSelectPart("({$compiled['sql']}){$aliasSql}"); + $this->selectParams = array_merge($this->selectParams, $compiled['params']); + return $this; + } + + /** + * Use a query builder result as the FROM source (derived table) + * + * @param self $query Builder instance for the subquery + * @param string $alias Required alias for the derived table + * @return self + * @throws \InvalidArgumentException If no alias is provided + * + * Example: + * Builder::table('users')->fromSubquery(Builder::table('users')->where(['active' => true]), 'u')->build(); + */ + public function fromSubquery(self $query, string $alias = '') : self { + if ($alias === '') { + throw new \InvalidArgumentException('fromSubquery() requires an alias for the derived table'); + } + $compiled = $query->compileSelect(false); + $this->fromSubquery = "({$compiled['sql']}) AS {$alias}"; + $this->fromParams = $compiled['params']; return $this; } @@ -173,6 +345,101 @@ public function insert(array $data) : self { return $this; } + /** + * Set query action to INSERT with multiple rows (one multi-row statement) + * + * @param array> $rows List of associative arrays ['column' => 'value'] + * @return self + * @throws \InvalidArgumentException If rows are empty or use inconsistent columns + * + * Example: + * Builder::table('users')->insertBatch([ + * ['name' => 'Alice', 'email' => 'alice@example.com'], + * ['name' => 'Bob', 'email' => 'bob@example.com'], + * ])->build(); + * // sql: INSERT INTO users (name, email) VALUES (?, ?), (?, ?) + */ + public function insertBatch(array $rows) : self { + if (empty($rows)) { + throw new \InvalidArgumentException('Insert data is empty'); + } + $this->action = 'insertBatch'; + $this->batchRows = $rows; + return $this; + } + + /** + * Set query action to INSERT with multiple rows with ON DUPLICATE KEY UPDATE (MySQL/MariaDB) + * + * @param array> $rows List of associative arrays ['column' => 'value'] + * @param array $uniqueKeys Columns that identify a duplicate key + * @return self + * @throws \InvalidArgumentException If rows are empty or all columns are unique keys + * + * Example: + * Builder::table('users')->upsertBatch( + * [['email' => 'a@example.com', 'points' => 1], ['email' => 'b@example.com', 'points' => 2]], + * ['email'] + * )->build(); + */ + public function upsertBatch(array $rows, array $uniqueKeys = []) : self { + if (empty($rows)) { + throw new \InvalidArgumentException('Insert data is empty'); + } + $this->action = 'upsertBatch'; + $this->batchRows = $rows; + $this->batchUniqueKeys = $uniqueKeys; + return $this; + } + + /** + * Set query action to UPDATE with a batch of rows (one multi-row statement) + * + * Each row must contain the value for the WHERE column plus the values to update. + * + * @param array> $rows List of associative arrays ['whereColumn' => 'value', 'column' => 'value'] + * @param string $whereColumn Column used to match rows in WHERE IN (...) + * @return self + * @throws \InvalidArgumentException If rows are empty or use inconsistent columns + * + * Example: + * Builder::table('users')->updateBatch([ + * ['id' => 1, 'name' => 'Alice'], + * ['id' => 2, 'name' => 'Bob'], + * ], 'id')->build(); + */ + public function updateBatch(array $rows, string $whereColumn) : self { + if (empty($rows)) { + throw new \InvalidArgumentException('Update data is empty'); + } + $this->action = 'updateBatch'; + $this->batchRows = $rows; + $this->batchWhereColumn = $whereColumn; + return $this; + } + + /** + * Set query action to DELETE with a batch of values + * + * @param string $whereColumn Column to match in WHERE IN (...) + * @param array $values List of values to delete + * @return self + * @throws \InvalidArgumentException If values are empty + * + * Example: + * Builder::table('users')->deleteBatch('id', [1, 2, 3])->build(); + * // sql: DELETE FROM users WHERE id IN (?, ?, ?) + */ + public function deleteBatch(string $whereColumn, array $values) : self { + if (empty($values)) { + throw new \InvalidArgumentException('Delete values are empty'); + } + $this->action = 'deleteBatch'; + $this->batchWhereColumn = $whereColumn; + $this->batchDeleteValues = $values; + return $this; + } + /** * Set query action to UPDATE (multiple calls merge data) * @@ -256,6 +523,55 @@ public function innerJoin(string $table, string $condition, string $alias = '') return $this->join($table, $condition, $alias, 'INNER'); } + /** + * Add a RIGHT JOIN clause + * + * @param string $table Table to join + * @param string $condition Join condition + * @param string $alias Table alias + * @return self + */ + public function rightJoin(string $table, string $condition, string $alias = '') : self { + return $this->join($table, $condition, $alias, 'RIGHT'); + } + + /** + * Add a UNION to the SELECT query + * + * @param self $query The query builder for the second part of the union + * @return self + * + * Example: + * Builder::table('users')->select(['name'])->where(['active' => true]) + * ->union(Builder::table('users')->select(['name'])->where(['archived' => true])) + * ->build(); + */ + public function union(self $query) : self { + return $this->addUnion($query, false); + } + + /** + * Add a UNION ALL to the SELECT query + * + * @param self $query The query builder for the second part of the union + * @return self + */ + public function unionAll(self $query) : self { + return $this->addUnion($query, true); + } + + /** + * Add a union query to the SELECT + * + * @param self $query The query builder for the second part of the union + * @param bool $all Whether to use UNION ALL + * @return self + */ + private function addUnion(self $query, bool $all) : self { + $this->unions[] = ['builder' => $query, 'all' => $all]; + return $this; + } + /** * Add WHERE conditions (automatically converts to prepared statement placeholders) * @@ -264,69 +580,10 @@ public function innerJoin(string $table, string $condition, string $alias = '') * @return self */ public function where(array $conditions) : self { - $whereConditions = []; - foreach ($conditions as $column => $value) { - if (is_array($value) && count($value) === 2) { - // Format: ['operator', 'value'] - [$operator, $operandValue] = $value; - if (strtoupper($operator) === 'BETWEEN') { - // Special handling for BETWEEN - $whereConditions[] = "{$column} BETWEEN ? AND ?"; - if (is_array($operandValue)) { - $this->params = array_merge($this->params, $operandValue); - } - } - else if (strtoupper($operator) === 'IN' && is_array($operandValue)) { - // Handle IN operator - $placeholders = implode(', ', array_fill(0, count($operandValue), '?')); - $whereConditions[] = "{$column} IN ({$placeholders})"; - $this->params = array_merge($this->params, $operandValue); - } - else if (strtoupper($operator) === 'NOT IN' && is_array($operandValue)) { - // Handle NOT IN operator - $placeholders = implode(', ', array_fill(0, count($operandValue), '?')); - $whereConditions[] = "{$column} NOT IN ({$placeholders})"; - $this->params = array_merge($this->params, $operandValue); - } - else if (strtoupper($operator) === 'IS' && $operandValue === null) { - // Handle IS NULL (no parameter binding) - $whereConditions[] = "{$column} IS NULL"; - } - else if (strtoupper($operator) === 'IS NOT' && $operandValue === null) { - // Handle IS NOT NULL (no parameter binding) - $whereConditions[] = "{$column} IS NOT NULL"; - } - else { - // General operators (=, !=, <, >, <=, >=, LIKE, etc.) - if ($operandValue instanceof BuilderRaw) { - // Raw SQL is inserted directly without binding - $whereConditions[] = "{$column} {$operator} {$operandValue->value}"; - // Support for raw expressions with bindings - if ($operandValue->hasBindings()) { - $this->params = array_merge($this->params, $operandValue->getBindings()); - } - } else { - $whereConditions[] = "{$column} {$operator} ?"; - $this->params[] = $operandValue; - } - } - } else { - // Simple 'value' format (default = operator) - if ($value instanceof BuilderRaw) { - // Raw SQL is inserted directly without binding - $whereConditions[] = "{$column} = {$value->value}"; - // Support for raw expressions with bindings - if ($value->hasBindings()) { - $this->params = array_merge($this->params, $value->getBindings()); - } - } else { - $whereConditions[] = "{$column} = ?"; - $this->params[] = $value; - } - } - } - if (!empty($whereConditions)) { - $this->where[] = implode(' AND ', $whereConditions); + $built = self::buildWhereConditions($conditions, 'AND'); + if ($built['sql'] !== '') { + $this->where[] = ['join' => 'AND', 'sql' => $built['sql']]; + $this->params = array_merge($this->params, $built['params']); } return $this; } @@ -340,166 +597,505 @@ public function where(array $conditions) : self { public function orWhere(array $conditions) : self { $group = self::buildWhereConditions($conditions, 'OR'); if (!empty($group['sql'])) { - $this->where[] = '(' . $group['sql'] . ')'; + $this->where[] = ['join' => 'AND', 'sql' => '(' . $group['sql'] . ')']; $this->params = array_merge($this->params, $group['params']); } return $this; } /** - * Add GROUP BY clause + * Add a WHERE IN condition (values become prepared placeholders) * - * @param string $groupBy Column(s) to group by + * Accepts a list of values or a query builder for an IN subquery. + * + * @param string $column Column name + * @param array|self $values List of values or a Builder for a subquery * @return self + * @throws \InvalidArgumentException If an empty array is provided + * + * Example: + * Builder::table('users')->whereIn('id', [1, 2, 3])->build(); + * // sql: SELECT * FROM users WHERE id IN (?, ?, ?) */ - public function groupBy(string $groupBy) : self { - $this->groupBy = $groupBy; + public function whereIn(string $column, $values) : self { + $this->where[] = ['join' => 'AND', 'sql' => $this->buildInSql($column, 'IN', $values)]; return $this; } /** - * Add ORDER BY clause + * Add an OR WHERE IN condition * - * @param string $orderBy Sort condition (e.g., 'id DESC', 'name ASC') + * @param string $column Column name + * @param array|self $values List of values or a Builder for a subquery * @return self */ - public function orderBy(string $orderBy) : self { - $this->orderBy = $orderBy; + public function orWhereIn(string $column, $values) : self { + $this->where[] = ['join' => 'AND', 'sql' => '(' . $this->buildInSql($column, 'IN', $values) . ')']; return $this; } /** - * Add LIMIT clause with optional OFFSET + * Add a WHERE NOT IN condition * - * @param int $limit Maximum number of rows to return - * @param int $offset Number of rows to skip + * @param string $column Column name + * @param array|self $values List of values or a Builder for a subquery * @return self */ - public function limit(int $limit, int $offset = 0) : self { - $this->limit = $limit; - $this->offset = $offset; + public function whereNotIn(string $column, $values) : self { + $this->where[] = ['join' => 'AND', 'sql' => $this->buildInSql($column, 'NOT IN', $values)]; return $this; } /** - * Clear WHERE conditions (allows query builder reuse) + * Add an OR WHERE NOT IN condition * + * @param string $column Column name + * @param array|self $values List of values or a Builder for a subquery * @return self */ - public function clearWhere() : self { - $this->where = []; - $this->params = []; + public function orWhereNotIn(string $column, $values) : self { + $this->where[] = ['join' => 'AND', 'sql' => '(' . $this->buildInSql($column, 'NOT IN', $values) . ')']; return $this; } /** - * Clear SELECT columns (reset to default) + * Build the SQL and parameters for an IN/NOT IN condition * - * @return self + * @param string $column Column name + * @param string $operator 'IN' or 'NOT IN' + * @param array|self $values List of values or a Builder for a subquery + * @return string The condition SQL + * @throws \InvalidArgumentException If an empty array is provided */ - public function clearSelect() : self { - $this->select = '*'; - return $this; + private function buildInSql(string $column, string $operator, $values) : string { + if ($values instanceof self) { + $compiled = $values->compileSelect(false); + $this->params = array_merge($this->params, $compiled['params']); + return "{$column} {$operator} ({$compiled['sql']})"; + } + if (!is_array($values) || empty($values)) { + throw new \InvalidArgumentException("{$operator} requires a non-empty list of values"); + } + $placeholders = implode(', ', array_fill(0, count($values), '?')); + $this->params = array_merge($this->params, array_values($values)); + return "{$column} {$operator} ({$placeholders})"; } /** - * Clear JOIN clauses + * Add a LIKE condition (wildcards escaped, value bound as a parameter) * + * The value is escaped so user input cannot inject unescaped wildcards. + * An explicit ESCAPE clause is emitted for compatibility with + * databases running with NO_BACKSLASH_ESCAPES. + * + * @param string $column Column name + * @param string|int|float $value Value to match (wildcards are escaped) + * @param string $position 'both', 'before', 'after', or 'none' * @return self + * @throws \InvalidArgumentException If position is invalid + * + * Example: + * Builder::table('products')->like('title', '50% off', 'both')->build(); + * // sql: SELECT * FROM products WHERE title LIKE ? ESCAPE '!' + * // params: ['%50!% off%'] */ - public function clearJoin() : self { - $this->joins = []; + public function like(string $column, $value, string $position = 'both') : self { + $this->addLike($column, 'LIKE', $value, $position); return $this; } /** - * Clear GROUP BY clause + * Add an OR LIKE condition * + * @param string $column Column name + * @param string|int|float $value Value to match (wildcards are escaped) + * @param string $position 'both', 'before', 'after', or 'none' * @return self */ - public function clearGroupBy() : self { - $this->groupBy = ''; + public function orLike(string $column, $value, string $position = 'both') : self { + $this->addLike($column, 'LIKE', $value, $position, true); return $this; } /** - * Clear ORDER BY clause + * Add a NOT LIKE condition * + * @param string $column Column name + * @param string|int|float $value Value to match (wildcards are escaped) + * @param string $position 'both', 'before', 'after', or 'none' * @return self */ - public function clearOrderBy() : self { - $this->orderBy = ''; + public function notLike(string $column, $value, string $position = 'both') : self { + $this->addLike($column, 'NOT LIKE', $value, $position); return $this; } /** - * Clear LIMIT and OFFSET + * Add an OR NOT LIKE condition * + * @param string $column Column name + * @param string|int|float $value Value to match (wildcards are escaped) + * @param string $position 'both', 'before', 'after', or 'none' * @return self */ - public function clearLimit() : self { - $this->limit = 0; - $this->offset = 0; + public function orNotLike(string $column, $value, string $position = 'both') : self { + $this->addLike($column, 'NOT LIKE', $value, $position, true); return $this; } /** - * Clear all query conditions (reset builder to initial state) + * Append a LIKE condition part * - * @return self + * @param string $column Column name + * @param string $operator 'LIKE' or 'NOT LIKE' + * @param string|int|float $value Value to match + * @param string $position 'both', 'before', 'after', or 'none' + * @param bool $or Wrap condition in parentheses as an OR group + * @return void + * @throws \InvalidArgumentException If position is invalid */ - public function clearAll() : self { - $this->select = '*'; - $this->joins = []; - $this->where = []; - $this->params = []; - $this->groupBy = ''; - $this->orderBy = ''; - $this->limit = 0; - $this->offset = 0; - $this->setData = []; - $this->onDuplicateKeyUpdateData = []; - return $this; - } + private function addLike(string $column, string $operator, $value, string $position, bool $or = false) : void { + if (!in_array($position, ['both', 'before', 'after', 'none'], true)) { + throw new \InvalidArgumentException( + "Invalid LIKE position: '{$position}'. Expected 'both', 'before', 'after', or 'none'." + ); + } + $escaped = $this->escapeLikeValue($value); + if ($position === 'both') { + $escaped = "%{$escaped}%"; + } elseif ($position === 'before') { + $escaped = "%{$escaped}"; + } elseif ($position === 'after') { + $escaped = "{$escaped}%"; + } + $this->params[] = $escaped; + $sql = "{$column} {$operator} ? ESCAPE '!'"; + $this->where[] = ['join' => 'AND', 'sql' => $or ? '(' . $sql . ')' : $sql]; + } /** - * Build and return the SQL query string + * Escape LIKE wildcards in a value * - * @return string The generated SQL query + * Escapes the escape character, '%' and '_' using '!' as the escape character. + * + * @param string|int|float $value Value to escape + * @return string Escaped value */ - public function buildSQL() : string { - $tableWithAlias = !empty($this->tableAlias) ? $this->table . ' AS ' . $this->tableAlias : $this->table; - $sql = "SELECT {$this->select} FROM {$tableWithAlias}"; - - // Add JOINs - if (!empty($this->joins)) { - $sql .= " " . implode(" ", $this->joins); - } - - // Add WHERE conditions - if (!empty($this->where)) { - $sql .= " WHERE " . implode(" AND ", $this->where); + private function escapeLikeValue($value) : string { + $str = (string) $value; + return str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $str); + } + + /** + * Start a nested condition group + * + * Groups must be balanced: every groupStart() needs a matching groupEnd(). + * + * @return self + * + * Example: + * Builder::table('users') + * ->where(['status' => 'active']) + * ->groupStart() + * ->where(['role' => 'admin']) + * ->where(['plan' => 'premium']) + * ->groupEnd() + * ->build(); + * // WHERE status = ? AND (role = ? AND plan = ?) + */ + public function groupStart() : self { + $this->where[] = ['join' => 'AND', 'sql' => '(']; + $this->groupDepth++; + return $this; + } + + /** + * Start a nested condition group joined with OR + * + * @return self + * + * Example: + * Builder::table('users') + * ->groupStart() + * ->where(['role' => 'admin']) + * ->orGroupStart() + * ->where(['role' => 'moderator']) + * ->where(['status' => 'active']) + * ->groupEnd() + * ->groupEnd() + * ->build(); + * // WHERE (role = ? OR (role = ? AND status = ?)) + */ + public function orGroupStart() : self { + $this->where[] = ['join' => 'OR', 'sql' => '(']; + $this->groupDepth++; + return $this; + } + + /** + * Start a nested condition group joined with AND NOT + * + * @return self + * + * Example: + * Builder::table('users') + * ->where(['status' => 'active']) + * ->notGroupStart() + * ->where(['role' => 'banned']) + * ->groupEnd() + * ->build(); + * // WHERE status = ? AND NOT (role = ?) + */ + public function notGroupStart() : self { + $this->where[] = ['join' => 'AND NOT', 'sql' => '(']; + $this->groupDepth++; + return $this; + } + + /** + * Close a nested condition group opened with groupStart()/orGroupStart()/notGroupStart() + * + * @return self + * @throws \InvalidArgumentException If there is no open group to close + */ + public function groupEnd() : self { + if ($this->groupDepth <= 0) { + throw new \InvalidArgumentException('groupEnd() called without a matching groupStart()'); } - - // Add GROUP BY - if (!empty($this->groupBy)) { - $sql .= " GROUP BY " . $this->groupBy; + $this->where[] = ['join' => 'AND', 'sql' => ')']; + $this->groupDepth--; + return $this; + } + + /** + * Add HAVING conditions (filters applied after GROUP BY) + * + * @param array $conditions Conditions in the same format as where() + * @return self + * + * Example: + * Builder::table('orders') + * ->select(['user_id']) + * ->selectCount('id', 'total_orders') + * ->groupBy('user_id') + * ->having(['total_orders' => ['>', 10]]) + * ->build(); + */ + public function having(array $conditions) : self { + $built = self::buildWhereConditions($conditions, 'AND'); + if ($built['sql'] !== '') { + $this->having[] = ['join' => 'AND', 'sql' => $built['sql']]; + $this->havingParams = array_merge($this->havingParams, $built['params']); } - - // Add ORDER BY - if (!empty($this->orderBy)) { - $sql .= " ORDER BY " . $this->orderBy; + return $this; + } + + /** + * Add OR HAVING conditions + * + * @param array $conditions Conditions in the same format as where() + * @return self + */ + public function orHaving(array $conditions) : self { + $group = self::buildWhereConditions($conditions, 'OR'); + if (!empty($group['sql'])) { + $this->having[] = ['join' => 'AND', 'sql' => '(' . $group['sql'] . ')']; + $this->havingParams = array_merge($this->havingParams, $group['params']); } - - // Add LIMIT - if ($this->limit > 0) { - $sql .= " LIMIT " . $this->limit; - if ($this->offset > 0) { - $sql .= " OFFSET " . $this->offset; + return $this; + } + + /** + * Add GROUP BY clause + * + * @param string $groupBy Column(s) to group by + * @return self + */ + public function groupBy(string $groupBy) : self { + $this->groupBy = $groupBy; + return $this; + } + + /** + * Add ORDER BY clause + * + * Two forms are supported: + * - orderBy('created_at DESC') - a full sort expression + * - orderBy('id', 'DESC') - a validated column with an ASC/DESC direction + * + * Calling orderBy() multiple times replaces the previous value. + * + * @param string $orderBy Sort expression or column name + * @param string|null $direction Optional direction ('ASC' or 'DESC') + * @return self + * @throws \InvalidArgumentException If direction is invalid or the column is unsafe + */ + public function orderBy(string $orderBy, ?string $direction = null) : self { + if ($direction !== null) { + $safeColumn = Builder::safeIdentifier($orderBy); + $dir = strtoupper($direction); + if (!in_array($dir, ['ASC', 'DESC'], true)) { + throw new \InvalidArgumentException( + "Invalid ORDER BY direction: '{$direction}'. Only ASC and DESC are allowed." + ); } + $this->orderBy = "{$safeColumn} {$dir}"; + return $this; } - - return $sql; + $this->orderBy = $orderBy; + return $this; + } + + /** + * Add LIMIT clause with optional OFFSET + * + * @param int $limit Maximum number of rows to return + * @param int $offset Number of rows to skip + * @return self + */ + public function limit(int $limit, int $offset = 0) : self { + $this->limit = $limit; + $this->offset = $offset; + return $this; + } + + /** + * Conditionally apply a callback to the builder + * + * The callback is only invoked when the condition is truthy. + * + * @param mixed $condition Condition to evaluate + * @param callable $callback Callback receiving the builder, e.g. function (self $query) : self + * @return self + * + * Example: + * Builder::table('users') + * ->when($search, function ($q) use ($search) { return $q->like('name', $search); }) + * ->build(); + */ + public function when($condition, callable $callback) : self { + if ($condition) { + $callback($this); + } + return $this; + } + + /** + * Conditionally apply a callback to the builder when the condition is falsy + * + * @param mixed $condition Condition to evaluate + * @param callable $callback Callback receiving the builder, e.g. function (self $query) : self + * @return self + */ + public function whenNot($condition, callable $callback) : self { + if (!$condition) { + $callback($this); + } + return $this; + } + + /** + * Clear WHERE conditions (allows query builder reuse) + * + * @return self + */ + public function clearWhere() : self { + $this->where = []; + $this->params = []; + $this->groupDepth = 0; + return $this; + } + + /** + * Clear SELECT columns (reset to default) + * + * @return self + */ + public function clearSelect() : self { + $this->select = '*'; + $this->selectParams = []; + return $this; + } + + /** + * Clear JOIN clauses + * + * @return self + */ + public function clearJoin() : self { + $this->joins = []; + return $this; + } + + /** + * Clear GROUP BY clause + * + * @return self + */ + public function clearGroupBy() : self { + $this->groupBy = ''; + return $this; + } + + /** + * Clear ORDER BY clause + * + * @return self + */ + public function clearOrderBy() : self { + $this->orderBy = ''; + return $this; + } + + /** + * Clear LIMIT and OFFSET + * + * @return self + */ + public function clearLimit() : self { + $this->limit = 0; + $this->offset = 0; + return $this; + } + + /** + * Clear all query conditions (reset builder to initial state) + * + * @return self + */ + public function clearAll() : self { + $this->select = '*'; + $this->selectParams = []; + $this->joins = []; + $this->where = []; + $this->params = []; + $this->fromParams = []; + $this->having = []; + $this->havingParams = []; + $this->groupBy = ''; + $this->orderBy = ''; + $this->limit = 0; + $this->offset = 0; + $this->setData = []; + $this->onDuplicateKeyUpdateData = []; + $this->distinct = false; + $this->groupDepth = 0; + $this->fromSubquery = ''; + $this->unions = []; + $this->batchRows = []; + $this->batchWhereColumn = ''; + $this->batchUniqueKeys = []; + $this->batchDeleteValues = []; + return $this; + } + + /** + * Build and return the SQL query string (always builds a SELECT query) + * + * @return string The generated SQL query + */ + public function buildSQL() : string { + return $this->compileSelect()['sql']; } /** @@ -514,16 +1110,18 @@ public function getParams() : array { /** * Build and return the SQL query with parameters * + * @param bool $reset Whether to reset the builder state after building (default: false) * @return array{sql: string, params: array} Associative array ['sql' => string, 'params' => array] * @throws \InvalidArgumentException If query data is invalid */ - public function build() : array { + public function build(bool $reset = false) : array { $result = []; switch ($this->action) { case 'select': + $compiled = $this->compileSelect(); $result = [ - 'sql' => $this->buildSQL(), - 'params' => $this->params + 'sql' => $compiled['sql'], + 'params' => $compiled['params'] ]; break; @@ -573,21 +1171,63 @@ public function build() : array { ]; break; + case 'insertBatch': + if (empty($this->batchRows)) { + throw new \InvalidArgumentException('insertBatch() requires at least one row'); + } + $result = $this->buildInsertBatch(); + break; + + case 'upsertBatch': + if (empty($this->batchRows)) { + throw new \InvalidArgumentException('upsertBatch() requires at least one row'); + } + $result = $this->buildUpsertBatch(); + break; + + case 'updateBatch': + if (empty($this->batchRows)) { + throw new \InvalidArgumentException('updateBatch() requires at least one row'); + } + $result = $this->buildUpdateBatch(); + break; + + case 'deleteBatch': + if (empty($this->batchDeleteValues)) { + throw new \InvalidArgumentException('deleteBatch() requires at least one value'); + } + $placeholders = implode(', ', array_fill(0, count($this->batchDeleteValues), '?')); + $result = [ + 'sql' => "DELETE FROM {$this->table} WHERE {$this->batchWhereColumn} IN ({$placeholders})", + 'params' => $this->batchDeleteValues + ]; + break; + case 'count': - $tableWithAlias = !empty($this->tableAlias) ? $this->table . ' AS ' . $this->tableAlias : $this->table; + $this->assertBalancedGroups(); + $tableWithAlias = !empty($this->fromSubquery) + ? $this->fromSubquery + : (!empty($this->tableAlias) ? $this->table . ' AS ' . $this->tableAlias : $this->table); $sql = "SELECT COUNT({$this->countColumn}) AS cnt FROM {$tableWithAlias}"; if (!empty($this->joins)) { $sql .= " " . implode(" ", $this->joins); } - if (!empty($this->where)) { - $sql .= " WHERE " . implode(" AND ", $this->where); + $whereSql = $this->renderParts($this->where); + if ($whereSql !== '') { + $sql .= " WHERE " . $whereSql; } if (!empty($this->groupBy)) { $sql .= " GROUP BY " . $this->groupBy; } + $havingSql = $this->renderParts($this->having); + if ($havingSql !== '') { + $sql .= " HAVING " . $havingSql; + } $result = [ 'sql' => $sql, - 'params' => $this->params + // COUNT does not render the SELECT list, so selectSubquery() + // parameters are excluded; FROM/WHERE/HAVING bindings remain. + 'params' => array_merge($this->fromParams, $this->params, $this->havingParams) ]; break; @@ -595,6 +1235,7 @@ public function build() : array { if (empty($this->setData)) { throw new \InvalidArgumentException('Update data is empty'); } + $this->assertBalancedGroups(); $sets = []; $params = []; foreach ($this->setData as $column => $value) { @@ -611,8 +1252,9 @@ public function build() : array { } } $sql = "UPDATE {$this->table} SET " . implode(', ', $sets); - if (!empty($this->where)) { - $sql .= " WHERE " . implode(" AND ", $this->where); + $whereSql = $this->renderParts($this->where); + if ($whereSql !== '') { + $sql .= " WHERE " . $whereSql; $params = array_merge($params, $this->params); } $result = [ @@ -622,10 +1264,12 @@ public function build() : array { break; case 'delete': + $this->assertBalancedGroups(); $tableWithAlias = !empty($this->tableAlias) ? $this->table . ' AS ' . $this->tableAlias : $this->table; $sql = "DELETE FROM {$tableWithAlias}"; - if (!empty($this->where)) { - $sql .= " WHERE " . implode(" AND ", $this->where); + $whereSql = $this->renderParts($this->where); + if ($whereSql !== '') { + $sql .= " WHERE " . $whereSql; } $result = [ 'sql' => $sql, @@ -642,7 +1286,7 @@ public function build() : array { 'table' => $this->table, 'alias' => $this->tableAlias, 'select' => $this->select, - 'where' => $this->where, + 'where' => array_column($this->where, 'sql'), 'joins' => $this->joins, 'groupBy' => $this->groupBy, 'orderBy' => $this->orderBy, @@ -651,9 +1295,160 @@ public function build() : array { 'setData' => $this->setData, ], $result); + if ($reset) { + $this->clearAll(); + // clearAll() intentionally preserves the action (existing behavior), + // so reset it here to make the opt-in build(true) reuse fully safe. + $this->action = 'select'; + } + return $result; } + /** + * Build a multi-row INSERT statement + * + * @return array{sql: string, params: array} + * @throws \InvalidArgumentException If rows use inconsistent columns + */ + private function buildInsertBatch() : array { + $columns = array_keys($this->batchRows[0]); + foreach ($this->batchRows as $row) { + if (array_keys($row) !== $columns) { + throw new \InvalidArgumentException('All rows must contain the same columns'); + } + } + + $colsSql = implode(', ', $columns); + $valuesSql = []; + $params = []; + foreach ($this->batchRows as $row) { + $rowSql = []; + foreach ($columns as $column) { + $value = $row[$column]; + if ($value instanceof BuilderRaw) { + $rowSql[] = $value->value; + if ($value->hasBindings()) { + $params = array_merge($params, $value->getBindings()); + } + } else { + $rowSql[] = '?'; + $params[] = $value; + } + } + $valuesSql[] = '(' . implode(', ', $rowSql) . ')'; + } + + return [ + 'sql' => "INSERT INTO {$this->table} ({$colsSql}) VALUES " . implode(', ', $valuesSql), + 'params' => $params + ]; + } + + /** + * Build a multi-row INSERT ... ON DUPLICATE KEY UPDATE statement + * + * @return array{sql: string, params: array} + * @throws \InvalidArgumentException If rows use inconsistent columns or all columns are unique keys + */ + private function buildUpsertBatch() : array { + $columns = array_keys($this->batchRows[0]); + foreach ($this->batchRows as $row) { + if (array_keys($row) !== $columns) { + throw new \InvalidArgumentException('All rows must contain the same columns'); + } + } + $updateColumns = array_values(array_diff($columns, $this->batchUniqueKeys)); + if (empty($updateColumns)) { + throw new \InvalidArgumentException('upsertBatch() requires at least one non-key column to update'); + } + + $colsSql = implode(', ', $columns); + $valuesSql = []; + $params = []; + foreach ($this->batchRows as $row) { + $rowSql = []; + foreach ($columns as $column) { + $value = $row[$column]; + if ($value instanceof BuilderRaw) { + $rowSql[] = $value->value; + if ($value->hasBindings()) { + $params = array_merge($params, $value->getBindings()); + } + } else { + $rowSql[] = '?'; + $params[] = $value; + } + } + $valuesSql[] = '(' . implode(', ', $rowSql) . ')'; + } + $updateSql = implode(', ', array_map( + static function (string $column) : string { return "{$column} = VALUES({$column})"; }, + $updateColumns + )); + + return [ + 'sql' => "INSERT INTO {$this->table} ({$colsSql}) VALUES " . implode(', ', $valuesSql) + . " ON DUPLICATE KEY UPDATE {$updateSql}", + 'params' => $params + ]; + } + + /** + * Build a multi-row UPDATE statement using CASE WHEN blocks + * + * @return array{sql: string, params: array} + * @throws \InvalidArgumentException If rows use inconsistent columns or lack the WHERE column + */ + private function buildUpdateBatch() : array { + $columns = array_keys($this->batchRows[0]); + foreach ($this->batchRows as $row) { + if (array_keys($row) !== $columns) { + throw new \InvalidArgumentException('All rows must contain the same columns'); + } + if (!array_key_exists($this->batchWhereColumn, $row)) { + throw new \InvalidArgumentException( + "Every row must contain the WHERE column '{$this->batchWhereColumn}'" + ); + } + } + + $setColumns = array_values(array_diff($columns, [$this->batchWhereColumn])); + if (empty($setColumns)) { + throw new \InvalidArgumentException( + 'updateBatch() requires at least one column to update besides the WHERE column' + ); + } + + $params = []; + $setSql = []; + foreach ($setColumns as $column) { + $whenSql = []; + foreach ($this->batchRows as $row) { + $whenSql[] = "WHEN {$this->batchWhereColumn} = ? THEN ?"; + $params[] = $row[$this->batchWhereColumn]; + $params[] = $row[$column]; + } + $setSql[] = "{$column} = CASE " . implode(' ', $whenSql) . ' END'; + } + + $whereValues = []; + foreach ($this->batchRows as $row) { + $whereValues[] = $row[$this->batchWhereColumn]; + } + $wherePlaceholders = implode(', ', array_fill(0, count($whereValues), '?')); + + foreach ($whereValues as $value) { + $params[] = $value; + } + + return [ + 'sql' => "UPDATE {$this->table} SET " . implode(', ', $setSql) + . " WHERE {$this->batchWhereColumn} IN ({$wherePlaceholders})", + 'params' => $params + ]; + } + /** * Alias for build() method * @@ -672,6 +1467,138 @@ public function getSQL() : string { return $this->buildSQL(); } + /** + * Compile a SELECT query into SQL and parameters + * + * Parameters are assembled in SQL order: SELECT subqueries, FROM subquery, + * WHERE conditions, HAVING conditions, then each UNION part. + * + * @param bool $withOrderLimit Whether to include ORDER BY and LIMIT (false for subqueries and union members) + * @return array{sql: string, params: array} + * @throws \InvalidArgumentException If condition groups are unbalanced + */ + private function compileSelect(bool $withOrderLimit = true) : array { + $this->assertBalancedGroups(); + $table = !empty($this->fromSubquery) + ? $this->fromSubquery + : (!empty($this->tableAlias) ? $this->table . ' AS ' . $this->tableAlias : $this->table); + + $sql = "SELECT "; + if ($this->distinct) { + $sql .= "DISTINCT "; + } + $sql .= "{$this->select} FROM {$table}"; + + if (!empty($this->joins)) { + $sql .= " " . implode(" ", $this->joins); + } + + $whereSql = $this->renderParts($this->where); + if ($whereSql !== '') { + $sql .= " WHERE " . $whereSql; + } + + if (!empty($this->groupBy)) { + $sql .= " GROUP BY " . $this->groupBy; + } + + $havingSql = $this->renderParts($this->having); + if ($havingSql !== '') { + $sql .= " HAVING " . $havingSql; + } + + if ($withOrderLimit && !empty($this->orderBy)) { + $sql .= " ORDER BY " . $this->orderBy; + } + + if ($withOrderLimit && $this->limit > 0) { + $sql .= " LIMIT " . $this->limit; + if ($this->offset > 0) { + $sql .= " OFFSET " . $this->offset; + } + } + + $params = $this->assembleParams(); + + foreach ($this->unions as $union) { + $compiled = $union['builder']->compileSelect(false); + $sql .= $union['all'] ? " UNION ALL " : " UNION "; + $sql .= $compiled['sql']; + $params = array_merge($params, $compiled['params']); + } + + return [ + 'sql' => $sql, + 'params' => $params + ]; + } + + /** + * Assemble all parameters in SQL order + * + * @return array + */ + private function assembleParams() : array { + return array_merge( + $this->selectParams, + $this->fromParams, + $this->params, + $this->havingParams + ); + } + + /** + * Throw if condition groups are unbalanced + * + * @return void + * @throws \InvalidArgumentException If condition groups are unbalanced + */ + private function assertBalancedGroups() : void { + if ($this->groupDepth !== 0) { + throw new \InvalidArgumentException( + "Unbalanced condition groups: {$this->groupDepth} groupStart() call(s) without matching groupEnd()" + ); + } + } + + /** + * Render condition parts into a SQL fragment + * + * Each part carries its join keyword ('AND', 'OR', etc.). Group openers and + * closers are rendered as parentheses, and the first condition inside a group + * is emitted without a connector. + * + * @param array $parts + * @return string + */ + private function renderParts(array $parts) : string { + $out = ''; + $suppressJoin = true; + foreach ($parts as $part) { + if ($part['sql'] === ')') { + $out .= ')'; + $suppressJoin = false; + continue; + } + if ($part['sql'] === '(') { + if (!$suppressJoin) { + $out .= ' ' . $part['join'] . ' '; + } elseif ($part['join'] === 'AND NOT') { + $out .= 'NOT '; + } + $out .= '('; + $suppressJoin = true; + continue; + } + if (!$suppressJoin) { + $out .= ' ' . $part['join'] . ' '; + } + $out .= $part['sql']; + $suppressJoin = false; + } + return $out; + } + /** * Parse WHERE conditions from array format * @@ -739,4 +1666,4 @@ private static function buildWhereConditions(array $conditions, string $implodeO 'params' => $params ]; } -} +} \ No newline at end of file diff --git a/src/QueryLogger.php b/src/QueryLogger.php index c30c67d..0e60c57 100644 --- a/src/QueryLogger.php +++ b/src/QueryLogger.php @@ -8,8 +8,10 @@ */ class QueryLogger { private static bool $enabled = false; + /** @var array, joins: array, groupBy: string, orderBy: string, limit: int, offset: int, setData: array}, output: array{sql: string, params: array}, timestamp: float}> */ private static array $queries = []; private static float $startTime = 0; + /** @var array */ private static array $metrics = [ 'total_queries' => 0, 'select_queries' => 0, @@ -31,12 +33,15 @@ public static function init() : void { /** * Log a query build + * + * @param array{table: string, alias: string, select: string, where: array, joins: array, groupBy: string, orderBy: string, limit: int, offset: int, setData: array} $input + * @param array{sql: string, params: array} $output */ public static function log(string $action, array $input, array $output) : void { if (!self::$enabled) return; self::$metrics['total_queries']++; - self::$metrics[$action . '_queries']++; + self::$metrics[$action . '_queries'] = (self::$metrics[$action . '_queries'] ?? 0) + 1; $duration = microtime(true) - self::$startTime; @@ -51,6 +56,8 @@ public static function log(string $action, array $input, array $output) : void { /** * Get all logged queries + * + * @return array, joins: array, groupBy: string, orderBy: string, limit: int, offset: int, setData: array}, output: array{sql: string, params: array}, timestamp: float}> */ public static function getQueries() : array { return self::$queries; @@ -58,6 +65,8 @@ public static function getQueries() : array { /** * Get metrics + * + * @return array */ public static function getMetrics() : array { return self::$metrics; diff --git a/src/QueryPanel.php b/src/QueryPanel.php index 1d7777f..9f35b57 100644 --- a/src/QueryPanel.php +++ b/src/QueryPanel.php @@ -183,6 +183,8 @@ private function renderStyles() : string { /** * Render summary cards + * + * @param array $metrics */ private function renderSummaryCards(array $metrics) : string { $html = '
'; @@ -224,6 +226,8 @@ private function renderSummaryCards(array $metrics) : string { /** * Render queries list + * + * @param array, joins: array, groupBy: string, orderBy: string, limit: int, offset: int, setData: array}, output: array{sql: string, params: array}, timestamp: float}> $queries */ private function renderQueriesList(array $queries) : string { $html = ''; @@ -263,6 +267,8 @@ private function renderQueriesList(array $queries) : string { /** * Render query details + * + * @param array{table: string, alias: string, select: string, where: array, joins: array, groupBy: string, orderBy: string, limit: int, offset: int, setData: array} $input */ private function renderQueryDetails(array $input) : string { $html = ''; diff --git a/tests/BuilderExtensionsTest.php b/tests/BuilderExtensionsTest.php new file mode 100644 index 0000000..598c559 --- /dev/null +++ b/tests/BuilderExtensionsTest.php @@ -0,0 +1,1537 @@ +selectSum('total', 'total_sum') + ->build(); + + $this->assertEquals('SELECT SUM(total) AS total_sum FROM orders', $q['sql']); + $this->assertEmpty($q['params']); + } + + /** + * Test selectSum without alias + */ + public function testSelectSumWithoutAlias(): void + { + $q = Builder::table('orders') + ->selectSum('total') + ->build(); + + $this->assertEquals('SELECT SUM(total) FROM orders', $q['sql']); + } + + /** + * Test selectAvg/selectMin/selectMax/selectCount + */ + public function testSelectAggregates(): void + { + $q = Builder::table('orders') + ->selectAvg('total', 'avg_total') + ->selectMin('total', 'min_total') + ->selectMax('total', 'max_total') + ->selectCount('id', 'cnt') + ->build(); + + $this->assertEquals( + 'SELECT AVG(total) AS avg_total, MIN(total) AS min_total, MAX(total) AS max_total, COUNT(id) AS cnt FROM orders', + $q['sql'] + ); + } + + /** + * Test aggregates append to an existing select list + */ + public function testAggregateAppendsToSelect(): void + { + $q = Builder::table('orders') + ->select(['user_id']) + ->selectCount('id', 'total_orders') + ->build(); + + $this->assertEquals('SELECT user_id, COUNT(id) AS total_orders FROM orders', $q['sql']); + } + + /** + * Test distinct flag + */ + public function testDistinct(): void + { + $q = Builder::table('users') + ->distinct() + ->select(['role', 'status']) + ->build(); + + $this->assertEquals('SELECT DISTINCT role, status FROM users', $q['sql']); + $this->assertEmpty($q['params']); + } + + /** + * Test selectSubquery with subquery params ordered before WHERE params + */ + public function testSelectSubquery(): void + { + $sub = Builder::table('orders') + ->selectCount('id', 'order_count') + ->where(['status' => 'paid']); + + $q = Builder::table('users') + ->select(['id']) + ->selectSubquery($sub, 'order_count') + ->where(['active' => true]) + ->build(); + + $this->assertEquals( + 'SELECT id, (SELECT COUNT(id) AS order_count FROM orders WHERE status = ?) AS order_count FROM users WHERE active = ?', + $q['sql'] + ); + $this->assertEquals(['paid', true], $q['params']); + } + + /** + * Test selectSubquery keeps param order when where() is called first + */ + public function testSelectSubqueryParamOrderWithWhereFirst(): void + { + $sub = Builder::table('orders') + ->selectCount('id', 'cnt') + ->where(['status' => 'paid']); + + $q = Builder::table('users') + ->where(['active' => true]) + ->select(['id']) + ->selectSubquery($sub, 'cnt') + ->build(); + + $this->assertStringContainsString( + 'SELECT id, (SELECT COUNT(id) AS cnt FROM orders WHERE status = ?) AS cnt FROM users WHERE active = ?', + $q['sql'] + ); + $this->assertEquals(['paid', true], $q['params']); + } + + /** + * Test fromSubquery with param ordering + */ + public function testFromSubquery(): void + { + $sub = Builder::table('users') + ->where(['active' => true]); + + $q = Builder::table('users') + ->fromSubquery($sub, 'u') + ->where(['u.role' => 'admin']) + ->build(); + + $this->assertEquals( + 'SELECT * FROM (SELECT * FROM users WHERE active = ?) AS u WHERE u.role = ?', + $q['sql'] + ); + $this->assertEquals([true, 'admin'], $q['params']); + } + + /** + * Test fromSubquery requires an alias + */ + public function testFromSubqueryRequiresAlias(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users') + ->fromSubquery(Builder::table('users'), ''); + } + + /** + * Test whereIn with an array of values + */ + public function testWhereIn(): void + { + $q = Builder::table('users') + ->whereIn('id', [1, 2, 3]) + ->build(); + + $this->assertEquals('SELECT * FROM users WHERE id IN (?, ?, ?)', $q['sql']); + $this->assertEquals([1, 2, 3], $q['params']); + } + + /** + * Test orWhereIn + */ + public function testOrWhereIn(): void + { + $q = Builder::table('users') + ->where(['role' => 'admin']) + ->orWhereIn('id', [1, 2]) + ->build(); + + $this->assertEquals('SELECT * FROM users WHERE role = ? AND (id IN (?, ?))', $q['sql']); + $this->assertEquals(['admin', 1, 2], $q['params']); + } + + /** + * Test whereNotIn + */ + public function testWhereNotIn(): void + { + $q = Builder::table('users') + ->whereNotIn('status', ['banned', 'deleted']) + ->build(); + + $this->assertEquals('SELECT * FROM users WHERE status NOT IN (?, ?)', $q['sql']); + $this->assertEquals(['banned', 'deleted'], $q['params']); + } + + /** + * Test orWhereNotIn + */ + public function testOrWhereNotIn(): void + { + $q = Builder::table('users') + ->where(['role' => 'admin']) + ->orWhereNotIn('status', ['banned']) + ->build(); + + $this->assertEquals('SELECT * FROM users WHERE role = ? AND (status NOT IN (?))', $q['sql']); + $this->assertEquals(['admin', 'banned'], $q['params']); + } + + /** + * Test whereIn throws for an empty array + */ + public function testWhereInThrowsForEmptyArray(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users')->whereIn('id', []); + } + + /** + * Test whereIn with a subquery builder and param ordering + */ + public function testWhereInWithSubquery(): void + { + $sub = Builder::table('orders') + ->select(['user_id']) + ->where(['total' => ['>=', 100]]); + + $q = Builder::table('users') + ->whereIn('id', $sub) + ->where(['active' => true]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE total >= ?) AND active = ?', + $q['sql'] + ); + $this->assertEquals([100, true], $q['params']); + } + + /** + * Test like with wildcard escaping and 'both' position (default) + */ + public function testLikeBoth(): void + { + $q = Builder::table('products') + ->like('title', '50% off') + ->build(); + + $this->assertEquals("SELECT * FROM products WHERE title LIKE ? ESCAPE '!'", $q['sql']); + $this->assertEquals(['%50!% off%'], $q['params']); + } + + /** + * Test like with 'before' position + */ + public function testLikeBefore(): void + { + $q = Builder::table('products') + ->like('title', '50% off', 'before') + ->build(); + + $this->assertEquals(['%50!% off'], $q['params']); + } + + /** + * Test like with 'after' position + */ + public function testLikeAfter(): void + { + $q = Builder::table('products') + ->like('title', '50% off', 'after') + ->build(); + + $this->assertEquals(['50!% off%'], $q['params']); + } + + /** + * Test like with 'none' position + */ + public function testLikeNone(): void + { + $q = Builder::table('products') + ->like('title', '50% off', 'none') + ->build(); + + $this->assertEquals(['50!% off'], $q['params']); + } + + /** + * Test like escapes underscores and the escape character + */ + public function testLikeEscapesUnderscoreAndEscapeChar(): void + { + $q = Builder::table('products') + ->like('code', 'A_B') + ->like('msg', '100!done') + ->build(); + + $this->assertEquals(['%A!_B%', '%100!!done%'], $q['params']); + } + + /** + * Test orLike + */ + public function testOrLike(): void + { + $q = Builder::table('users') + ->where(['role' => 'admin']) + ->orLike('name', 'john') + ->build(); + + $this->assertEquals( + "SELECT * FROM users WHERE role = ? AND (name LIKE ? ESCAPE '!')", + $q['sql'] + ); + $this->assertEquals(['admin', '%john%'], $q['params']); + } + + /** + * Test notLike + */ + public function testNotLike(): void + { + $q = Builder::table('users') + ->notLike('name', 'banned', 'before') + ->build(); + + $this->assertEquals("SELECT * FROM users WHERE name NOT LIKE ? ESCAPE '!'", $q['sql']); + $this->assertEquals(['%banned'], $q['params']); + } + + /** + * Test orNotLike + */ + public function testOrNotLike(): void + { + $q = Builder::table('users') + ->where(['role' => 'admin']) + ->orNotLike('name', 'temp', 'after') + ->build(); + + $this->assertEquals( + "SELECT * FROM users WHERE role = ? AND (name NOT LIKE ? ESCAPE '!')", + $q['sql'] + ); + $this->assertEquals(['admin', 'temp%'], $q['params']); + } + + /** + * Test like throws for an invalid position + */ + public function testLikeThrowsForInvalidPosition(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users')->like('name', 'john', 'sideways'); + } + + /** + * Test existing ['LIKE', '%value%'] array form still works + */ + public function testExistingLikeArrayFormUnaffected(): void + { + $q = Builder::table('users') + ->where(['name' => ['LIKE', '%cake%']]) + ->build(); + + $this->assertEquals('SELECT * FROM users WHERE name LIKE ?', $q['sql']); + $this->assertEquals(['%cake%'], $q['params']); + } + + /** + * Test nested groups building (a OR (b AND c)) + */ + public function testNestedGroups(): void + { + $q = Builder::table('users') + ->groupStart() + ->where(['role' => 'admin']) + ->orGroupStart() + ->where(['role' => 'moderator']) + ->where(['status' => 'active']) + ->groupEnd() + ->groupEnd() + ->build(); + + $this->assertEquals( + 'SELECT * FROM users WHERE (role = ? OR (role = ? AND status = ?))', + $q['sql'] + ); + $this->assertEquals(['admin', 'moderator', 'active'], $q['params']); + } + + /** + * Test simple group with AND conditions + */ + public function testSimpleGroup(): void + { + $q = Builder::table('users') + ->where(['status' => 'active']) + ->groupStart() + ->where(['role' => 'admin']) + ->where(['verified' => true]) + ->groupEnd() + ->build(); + + $this->assertEquals( + 'SELECT * FROM users WHERE status = ? AND (role = ? AND verified = ?)', + $q['sql'] + ); + $this->assertEquals(['active', 'admin', true], $q['params']); + } + + /** + * Test notGroupStart + */ + public function testNotGroupStart(): void + { + $q = Builder::table('users') + ->where(['status' => 'active']) + ->notGroupStart() + ->where(['role' => 'banned']) + ->groupEnd() + ->build(); + + $this->assertEquals('SELECT * FROM users WHERE status = ? AND NOT (role = ?)', $q['sql']); + $this->assertEquals(['active', 'banned'], $q['params']); + } + + /** + * Test notGroupStart as the leading clause still emits NOT + */ + public function testLeadingNotGroupStart(): void + { + $q = Builder::table('users') + ->notGroupStart() + ->where(['role' => 'banned']) + ->groupEnd() + ->build(); + + $this->assertEquals('SELECT * FROM users WHERE NOT (role = ?)', $q['sql']); + $this->assertEquals(['banned'], $q['params']); + } + + /** + * Test build() throws for unbalanced groups + */ + public function testUnbalancedGroupsThrowOnBuild(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users') + ->groupStart() + ->where(['role' => 'admin']) + ->build(); + } + + /** + * Test groupEnd() throws without a matching groupStart() + */ + public function testGroupEndWithoutGroupStartThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users')->groupEnd(); + } + + /** + * Test clearWhere() resets group state + */ + public function testClearWhereResetsGroups(): void + { + $query = Builder::table('users') + ->groupStart() + ->where(['role' => 'admin']); + + $query->clearWhere(); + $q = $query->build(); + + $this->assertEquals('SELECT * FROM users', $q['sql']); + } + + /** + * Test having() after GROUP BY + */ + public function testHaving(): void + { + $q = Builder::table('orders') + ->select(['user_id']) + ->selectCount('id', 'total_orders') + ->groupBy('user_id') + ->having(['total_orders' => ['>', 10]]) + ->build(); + + $this->assertEquals( + 'SELECT user_id, COUNT(id) AS total_orders FROM orders GROUP BY user_id HAVING total_orders > ?', + $q['sql'] + ); + $this->assertEquals([10], $q['params']); + } + + /** + * Test orHaving() + */ + public function testOrHaving(): void + { + $q = Builder::table('orders') + ->select(['user_id']) + ->selectCount('id', 'total_orders') + ->groupBy('user_id') + ->having(['total_orders' => ['>', 10]]) + ->orHaving(['total_orders' => ['<', 5]]) + ->build(); + + $this->assertEquals( + 'SELECT user_id, COUNT(id) AS total_orders FROM orders GROUP BY user_id HAVING total_orders > ? AND (total_orders < ?)', + $q['sql'] + ); + $this->assertEquals([10, 5], $q['params']); + } + + /** + * Test rightJoin + */ + public function testRightJoin(): void + { + $q = Builder::table('users') + ->alias('u') + ->select(['u.id', 'o.total']) + ->rightJoin('orders', 'u.id = o.user_id', 'o') + ->build(); + + $this->assertEquals( + 'SELECT u.id, o.total FROM users AS u RIGHT JOIN orders AS o ON u.id = o.user_id', + $q['sql'] + ); + } + + /** + * Test union + */ + public function testUnion(): void + { + $q = Builder::table('users') + ->select(['name']) + ->where(['active' => true]) + ->union( + Builder::table('users') + ->select(['name']) + ->where(['archived' => true]) + ) + ->build(); + + $this->assertEquals( + 'SELECT name FROM users WHERE active = ? UNION SELECT name FROM users WHERE archived = ?', + $q['sql'] + ); + $this->assertEquals([true, true], $q['params']); + } + + /** + * Test unionAll + */ + public function testUnionAll(): void + { + $q = Builder::table('users') + ->select(['name']) + ->unionAll(Builder::table('users')->select(['name'])) + ->build(); + + $this->assertEquals('SELECT name FROM users UNION ALL SELECT name FROM users', $q['sql']); + $this->assertEmpty($q['params']); + } + + /** + * Test union strips ORDER BY and LIMIT from the member query + */ + public function testUnionStripsMemberOrderAndLimit(): void + { + $q = Builder::table('users') + ->select(['name']) + ->union( + Builder::table('users') + ->select(['name']) + ->where(['x' => 1]) + ->orderBy('name DESC') + ->limit(3) + ) + ->build(); + + $this->assertEquals( + 'SELECT name FROM users UNION SELECT name FROM users WHERE x = ?', + $q['sql'] + ); + $this->assertEquals([1], $q['params']); + } + + /** + * Test insertBatch builds a single multi-row statement + */ + public function testInsertBatch(): void + { + $q = Builder::table('users') + ->insertBatch([ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Bob', 'email' => 'bob@example.com'], + ]) + ->build(); + + $this->assertEquals( + 'INSERT INTO users (name, email) VALUES (?, ?), (?, ?)', + $q['sql'] + ); + $this->assertEquals(['Alice', 'alice@example.com', 'Bob', 'bob@example.com'], $q['params']); + } + + /** + * Test insertBatch throws for empty rows + */ + public function testInsertBatchThrowsForEmptyRows(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users')->insertBatch([]); + } + + /** + * Test insertBatch throws for inconsistent columns + */ + public function testInsertBatchThrowsForInconsistentColumns(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users')->insertBatch([ + ['name' => 'Alice', 'email' => 'alice@example.com'], + ['name' => 'Bob'], + ])->build(); + } + + /** + * Test insertBatch inlines raw values into the SQL instead of binding them + */ + public function testInsertBatchWithRawValue(): void + { + $q = Builder::table('users') + ->insertBatch([ + ['name' => 'Alice', 'created_at' => Builder::raw('NOW()')], + ]) + ->build(); + + $this->assertEquals( + 'INSERT INTO users (name, created_at) VALUES (?, NOW())', + $q['sql'] + ); + $this->assertEquals(['Alice'], $q['params']); + } + + /** + * Test updateBatch builds a single multi-row statement + */ + public function testUpdateBatch(): void + { + $q = Builder::table('users') + ->updateBatch([ + ['id' => 1, 'name' => 'Alice'], + ['id' => 2, 'name' => 'Bob'], + ], 'id') + ->build(); + + $this->assertEquals( + 'UPDATE users SET name = CASE WHEN id = ? THEN ? WHEN id = ? THEN ? END WHERE id IN (?, ?)', + $q['sql'] + ); + $this->assertEquals([1, 'Alice', 2, 'Bob', 1, 2], $q['params']); + } + + /** + * Test updateBatch with multiple update columns + */ + public function testUpdateBatchMultipleColumns(): void + { + $q = Builder::table('users') + ->updateBatch([ + ['id' => 1, 'name' => 'Alice', 'status' => 'active'], + ['id' => 2, 'name' => 'Bob', 'status' => 'inactive'], + ], 'id') + ->build(); + + $this->assertEquals( + 'UPDATE users SET name = CASE WHEN id = ? THEN ? WHEN id = ? THEN ? END, status = CASE WHEN id = ? THEN ? WHEN id = ? THEN ? END WHERE id IN (?, ?)', + $q['sql'] + ); + $this->assertEquals([1, 'Alice', 2, 'Bob', 1, 'active', 2, 'inactive', 1, 2], $q['params']); + } + + /** + * Test updateBatch throws when a row is missing the WHERE column + * + * Rows share the same columns so the failure comes from the WHERE column + * check, not the "same columns" check. + */ + public function testUpdateBatchThrowsForMissingWhereColumn(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users') + ->updateBatch([ + ['name' => 'Alice'], + ['name' => 'Bob'], + ], 'id') + ->build(); + } + + /** + * Test updateBatch throws when rows use inconsistent columns + */ + public function testUpdateBatchThrowsForInconsistentColumns(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users') + ->updateBatch([ + ['id' => 1, 'name' => 'Alice'], + ['id' => 2], + ], 'id') + ->build(); + } + + /** + * Test updateBatch throws when the only column is the WHERE column + */ + public function testUpdateBatchThrowsWhenOnlyWhereColumn(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users') + ->updateBatch([ + ['id' => 1], + ['id' => 2], + ], 'id') + ->build(); + } + + /** + * Test updateBatch throws for empty rows + */ + public function testUpdateBatchThrowsForEmptyRows(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users')->updateBatch([], 'id'); + } + + /** + * Test upsertBatch builds a single statement + */ + public function testUpsertBatch(): void + { + $q = Builder::table('users') + ->upsertBatch([ + ['email' => 'a@example.com', 'points' => 1], + ['email' => 'b@example.com', 'points' => 2], + ], ['email']) + ->build(); + + $this->assertEquals( + 'INSERT INTO users (email, points) VALUES (?, ?), (?, ?) ON DUPLICATE KEY UPDATE points = VALUES(points)', + $q['sql'] + ); + $this->assertEquals(['a@example.com', 1, 'b@example.com', 2], $q['params']); + } + + /** + * Test upsertBatch without unique keys updates all columns + */ + public function testUpsertBatchWithoutUniqueKeys(): void + { + $q = Builder::table('users') + ->upsertBatch([ + ['email' => 'a@example.com', 'points' => 1], + ]) + ->build(); + + $this->assertEquals( + 'INSERT INTO users (email, points) VALUES (?, ?) ON DUPLICATE KEY UPDATE email = VALUES(email), points = VALUES(points)', + $q['sql'] + ); + $this->assertEquals(['a@example.com', 1], $q['params']); + } + + /** + * Test upsertBatch throws when every column is a unique key + */ + public function testUpsertBatchThrowsWhenAllColumnsAreKeys(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users') + ->upsertBatch([ + ['email' => 'a@example.com', 'points' => 1], + ], ['email', 'points']) + ->build(); + } + + /** + * Test deleteBatch + */ + public function testDeleteBatch(): void + { + $q = Builder::table('users') + ->deleteBatch('id', [1, 2, 3]) + ->build(); + + $this->assertEquals('DELETE FROM users WHERE id IN (?, ?, ?)', $q['sql']); + $this->assertEquals([1, 2, 3], $q['params']); + } + + /** + * Test deleteBatch throws for empty values + */ + public function testDeleteBatchThrowsForEmptyValues(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users')->deleteBatch('id', []); + } + + /** + * Test when() runs the callback for a truthy condition + */ + public function testWhenTruthy(): void + { + $q = Builder::table('users') + ->when(true, function ($query) { + $query->where(['status' => 'active']); + }) + ->build(); + + $this->assertStringContainsString('WHERE status = ?', $q['sql']); + $this->assertEquals(['active'], $q['params']); + } + + /** + * Test when() skips the callback for a falsy condition + */ + public function testWhenFalsy(): void + { + $q = Builder::table('users') + ->when(false, function ($query) { + $query->where(['status' => 'active']); + }) + ->build(); + + $this->assertEquals('SELECT * FROM users', $q['sql']); + $this->assertEmpty($q['params']); + } + + /** + * Test when() keeps fluent chaining + */ + public function testWhenChaining(): void + { + $q = Builder::table('users') + ->when(false, function ($query) { + $query->where(['status' => 'active']); + }) + ->when(true, function ($query) { + $query->where(['role' => 'admin']); + }) + ->build(); + + $this->assertEquals('SELECT * FROM users WHERE role = ?', $q['sql']); + $this->assertEquals(['admin'], $q['params']); + } + + /** + * Test whenNot() runs the callback for a falsy condition + */ + public function testWhenNotFalsy(): void + { + $q = Builder::table('users') + ->whenNot(null, function ($query) { + $query->where(['archived' => false]); + }) + ->build(); + + $this->assertStringContainsString('WHERE archived = ?', $q['sql']); + $this->assertEquals([false], $q['params']); + } + + /** + * Test whenNot() skips the callback for a truthy condition + */ + public function testWhenNotTruthy(): void + { + $q = Builder::table('users') + ->whenNot(true, function ($query) { + $query->where(['archived' => false]); + }) + ->build(); + + $this->assertEquals('SELECT * FROM users', $q['sql']); + } + + /** + * Test orderBy() second form with validated direction + */ + public function testOrderByWithDirection(): void + { + $q = Builder::table('users') + ->orderBy('id', 'DESC') + ->build(); + + $this->assertEquals('SELECT * FROM users ORDER BY id DESC', $q['sql']); + } + + /** + * Test orderBy() second form is case-insensitive for direction + */ + public function testOrderByDirectionCaseInsensitive(): void + { + $q = Builder::table('orders') + ->orderBy('total', 'asc') + ->build(); + + $this->assertEquals('SELECT * FROM orders ORDER BY total ASC', $q['sql']); + } + + /** + * Test orderBy() throws for an invalid direction + */ + public function testOrderByThrowsForInvalidDirection(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users')->orderBy('id', 'SIDEWAYS'); + } + + /** + * Test orderBy() second form validates the column identifier + */ + public function testOrderByThrowsForUnsafeColumn(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users')->orderBy('id; DROP TABLE users--', 'DESC'); + } + + /** + * Test multiple orderBy() calls replace (both forms) + */ + public function testOrderByMultipleCallsReplace(): void + { + $q = Builder::table('users') + ->orderBy('name ASC') + ->orderBy('id', 'DESC') + ->build(); + + $this->assertEquals('SELECT * FROM users ORDER BY id DESC', $q['sql']); + + $q2 = Builder::table('users') + ->orderBy('name', 'ASC') + ->orderBy('id DESC') + ->build(); + + $this->assertEquals('SELECT * FROM users ORDER BY id DESC', $q2['sql']); + } + + /** + * Test build(true) resets the builder state after building + */ + public function testBuildWithReset(): void + { + $query = Builder::table('users') + ->where(['status' => 'active']) + ->limit(5); + + $result = $query->build(true); + $this->assertStringContainsString('WHERE status = ?', $result['sql']); + + $reset = $query->build(); + $this->assertEquals('SELECT * FROM users', $reset['sql']); + $this->assertEmpty($reset['params']); + } + + /** + * Test build() without reset keeps state (existing default behavior) + */ + public function testBuildWithoutResetKeepsState(): void + { + $query = Builder::table('users') + ->where(['status' => 'active']) + ->limit(5); + + $first = $query->build(); + $second = $query->build(); + + $this->assertStringContainsString('WHERE status = ?', $first['sql']); + $this->assertStringContainsString('WHERE status = ?', $second['sql']); + $this->assertStringContainsString('LIMIT 5', $second['sql']); + } + + /** + * Test clearAll() also clears the new builder state + */ + public function testClearAllClearsNewState(): void + { + $query = Builder::table('users') + ->distinct() + ->selectSum('points', 'total') + ->where(['status' => 'active']) + ->orderBy('id', 'DESC'); + + $query->clearAll(); + + $q = $query->build(); + $this->assertEquals('SELECT * FROM users', $q['sql']); + $this->assertEmpty($q['params']); + } + + /** + * Test clearAll() preserves the action (existing behavior) + */ + public function testClearAllPreservesAction(): void + { + $q = Builder::table('users') + ->delete() + ->where(['id' => 1]) + ->clearAll() + ->build(); + + $this->assertEquals('DELETE FROM users', $q['sql']); + $this->assertEmpty($q['params']); + } + + /** + * Test buildSQL() includes HAVING and DISTINCT + */ + public function testBuildSQLWithHavingAndDistinct(): void + { + $sql = Builder::table('orders') + ->distinct() + ->select(['user_id']) + ->selectCount('id', 'cnt') + ->groupBy('user_id') + ->having(['cnt' => ['>', 5]]) + ->buildSQL(); + + $this->assertStringContainsString( + 'SELECT DISTINCT user_id, COUNT(id) AS cnt FROM orders GROUP BY user_id HAVING cnt > ?', + $sql + ); + } + + /** + * Test count() keeps JOIN clauses (regression) + */ + public function testCountKeepsJoins(): void + { + $q = Builder::table('users') + ->alias('u') + ->innerJoin('posts', 'u.id = p.user_id', 'p') + ->count() + ->where(['u.status' => 'active']) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS cnt FROM users AS u INNER JOIN posts AS p ON u.id = p.user_id WHERE u.status = ?', + $q['sql'] + ); + $this->assertEquals(['active'], $q['params']); + } + + /** + * Test getParams() keeps its original behavior: it returns only the WHERE + * parameters. HAVING, select-subquery and UNION parameters are not included; + * the complete parameter list is available from build()['params']. + */ + public function testGetParamsReturnsOnlyWhereParams(): void + { + $query = Builder::table('orders') + ->selectCount('id', 'cnt') + ->groupBy('user_id') + ->having(['cnt' => ['>', 5]]); + + $this->assertEquals([], $query->getParams()); + $this->assertEquals([5], $query->build()['params']); + } + + /** + * Test build(true) resets the action so a write query can be reused as SELECT + */ + public function testBuildWithResetResetsAction(): void + { + $query = Builder::table('users') + ->update(['status' => 'inactive']) + ->where(['id' => 1]); + + $first = $query->build(true); + $this->assertStringContainsString('UPDATE users SET status = ?', $first['sql']); + + $second = $query->build(); + $this->assertEquals('SELECT * FROM users', $second['sql']); + $this->assertEmpty($second['params']); + } + + /** + * Test select() accepts a plain string column list + */ + public function testSelectAcceptsStringColumns(): void + { + $q = Builder::table('users')->select('id, name')->build(); + + $this->assertEquals('SELECT id, name FROM users', $q['sql']); + $this->assertEmpty($q['params']); + } + + /** + * Test join() without an alias derives one from the first letter of the table + */ + public function testJoinWithoutAliasUsesFirstLetter(): void + { + $q = Builder::table('users') + ->alias('u') + ->join('posts', 'u.id = p.user_id') + ->build(); + + $this->assertEquals( + 'SELECT * FROM users AS u INNER JOIN posts AS p ON u.id = p.user_id', + $q['sql'] + ); + } + + /** + * Test count() with a FROM subquery keeps the subquery parameters + */ + public function testCountWithFromSubquery(): void + { + $sub = Builder::table('users')->select(['id'])->where(['active' => true]); + + $q = Builder::table('users') + ->fromSubquery($sub, 'u') + ->count() + ->build(); + + $this->assertEquals( + 'SELECT COUNT(*) AS cnt FROM (SELECT id FROM users WHERE active = ?) AS u', + $q['sql'] + ); + $this->assertEquals([true], $q['params']); + } + + /** + * Test count() does not include select-subquery parameters, since its SQL + * never renders the SELECT list + */ + public function testCountExcludesSelectSubqueryParams(): void + { + $q = Builder::table('users') + ->selectSubquery( + Builder::table('orders')->selectCount('id')->where(['status' => 'paid']), + 'order_count' + ) + ->count() + ->where(['active' => true]) + ->build(); + + $this->assertEquals('SELECT COUNT(*) AS cnt FROM users WHERE active = ?', $q['sql']); + $this->assertEquals([true], $q['params']); + $this->assertSame(substr_count($q['sql'], '?'), count($q['params'])); + } + + /** + * Test count() with GROUP BY and HAVING + */ + public function testCountWithGroupByAndHaving(): void + { + $q = Builder::table('orders') + ->count('id') + ->groupBy('user_id') + ->having(['cnt' => ['>', 5]]) + ->build(); + + $this->assertEquals( + 'SELECT COUNT(id) AS cnt FROM orders GROUP BY user_id HAVING cnt > ?', + $q['sql'] + ); + $this->assertEquals([5], $q['params']); + } + + /** + * Test delete() uses the base table; fromSubquery() only applies to SELECT/COUNT + */ + public function testDeleteUsesBaseTableNotFromSubquery(): void + { + $q = Builder::table('users') + ->fromSubquery(Builder::table('users')->select(['id'])->where(['active' => true]), 'u') + ->delete() + ->where(['role' => 'admin']) + ->build(); + + $this->assertEquals('DELETE FROM users WHERE role = ?', $q['sql']); + $this->assertEquals(['admin'], $q['params']); + } + + /** + * Test build() throws when insert data is empty + */ + public function testInsertBuildThrowsForEmptyData(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users')->insert([])->build(); + } + + /** + * Test build() throws when update data is empty + */ + public function testUpdateBuildThrowsForEmptyData(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('users')->update([])->build(); + } + + /** + * Test INSERT with a raw expression that has bound parameters + */ + public function testInsertRawWithBindings(): void + { + $q = Builder::table('orders') + ->insert(['total' => Builder::raw('COALESCE(subtotal, ?) + ?', [0, 10])]) + ->build(); + + $this->assertEquals('INSERT INTO orders SET total = COALESCE(subtotal, ?) + ?', $q['sql']); + $this->assertEquals([0, 10], $q['params']); + } + + /** + * Test ON DUPLICATE KEY UPDATE with a raw expression that has bound parameters + */ + public function testOnDuplicateKeyUpdateRawWithBindings(): void + { + $q = Builder::table('user_stats') + ->insert(['user_id' => 1, 'views' => 0]) + ->onDuplicateKeyUpdate(['views' => Builder::raw('views + ?', [5])]) + ->build(); + + $this->assertEquals( + 'INSERT INTO user_stats SET user_id = ?, views = ? ON DUPLICATE KEY UPDATE views = views + ?', + $q['sql'] + ); + $this->assertEquals([1, 0, 5], $q['params']); + } + + /** + * Test where() with an operator and a raw expression carrying bindings + */ + public function testWhereRawOperatorWithBindings(): void + { + $q = Builder::table('products') + ->where(['price' => ['>', Builder::raw('(SELECT AVG(price) * ? FROM products)', [0.5])]]) + ->build(); + + $this->assertEquals( + 'SELECT * FROM products WHERE price > (SELECT AVG(price) * ? FROM products)', + $q['sql'] + ); + $this->assertEquals([0.5], $q['params']); + } + + /** + * Test where() with a simple value equal to a raw expression carrying bindings + */ + public function testWhereRawValueWithBindings(): void + { + $q = Builder::table('products') + ->where(['stock' => Builder::raw('COALESCE(?, 0)', [7])]) + ->build(); + + $this->assertEquals('SELECT * FROM products WHERE stock = COALESCE(?, 0)', $q['sql']); + $this->assertEquals([7], $q['params']); + } + + /** + * Test insertBatch() with a raw expression that has bound parameters + */ + public function testInsertBatchRawWithBindings(): void + { + $q = Builder::table('users') + ->insertBatch([ + ['name' => 'Alice', 'score' => Builder::raw('COALESCE(?, 0)', [5])], + ]) + ->build(); + + $this->assertEquals( + 'INSERT INTO users (name, score) VALUES (?, COALESCE(?, 0))', + $q['sql'] + ); + $this->assertEquals(['Alice', 5], $q['params']); + } + + /** + * Test upsertBatch() with a raw expression that has bound parameters + */ + public function testUpsertBatchRawWithBindings(): void + { + $q = Builder::table('user_stats') + ->upsertBatch([ + ['user_id' => 1, 'views' => Builder::raw('VALUES(views) + ?', [2])], + ], ['user_id']) + ->build(); + + $this->assertEquals( + 'INSERT INTO user_stats (user_id, views) VALUES (?, VALUES(views) + ?) ON DUPLICATE KEY UPDATE views = VALUES(views)', + $q['sql'] + ); + $this->assertEquals([1, 2], $q['params']); + } + + /** + * Test upsertBatch() throws for empty rows + */ + public function testUpsertBatchThrowsForEmptyRows(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('user_stats')->upsertBatch([]); + } + + /** + * Test upsertBatch() throws for inconsistent columns + */ + public function testUpsertBatchThrowsForInconsistentColumns(): void + { + $this->expectException(\InvalidArgumentException::class); + Builder::table('user_stats') + ->upsertBatch([ + ['user_id' => 1, 'views' => 1], + ['user_id' => 2], + ], ['user_id']) + ->build(); + } + + /** + * Test getSQL() (alias of buildSQL()) returns the SELECT SQL + */ + public function testGetSqlAlias(): void + { + $sql = Builder::table('users')->where(['id' => 1])->getSQL(); + + $this->assertEquals('SELECT * FROM users WHERE id = ?', $sql); + } + + /** + * Test selectSubquery() without an alias + */ + public function testSelectSubqueryWithoutAlias(): void + { + $q = Builder::table('users') + ->selectSubquery(Builder::table('orders')->selectCount('id')) + ->build(); + + $this->assertEquals('SELECT (SELECT COUNT(id) FROM orders) FROM users', $q['sql']); + $this->assertEmpty($q['params']); + } + + /** + * Test nested subqueries keep their parameters in order + */ + public function testNestedSubqueriesParamOrder(): void + { + $inner = Builder::table('a')->select(['id'])->where(['x' => 1]); + $mid = Builder::table('b')->selectSubquery($inner, 'inner_id')->where(['y' => 2]); + + $q = Builder::table('c') + ->selectSubquery($mid, 'mid_id') + ->where(['z' => 3]) + ->build(); + + $this->assertEquals( + 'SELECT (SELECT (SELECT id FROM a WHERE x = ?) AS inner_id FROM b WHERE y = ?) AS mid_id FROM c WHERE z = ?', + $q['sql'] + ); + $this->assertEquals([1, 2, 3], $q['params']); + } + + /** + * Test multiple UNION parts keep their parameters in order + */ + public function testMultipleUnionsParamOrder(): void + { + $q = Builder::table('a') + ->select(['id']) + ->where(['x' => 1]) + ->union(Builder::table('a')->select(['id'])->where(['x' => 2])) + ->unionAll(Builder::table('a')->select(['id'])->where(['x' => 3])) + ->build(); + + $this->assertEquals( + 'SELECT id FROM a WHERE x = ? UNION SELECT id FROM a WHERE x = ? UNION ALL SELECT id FROM a WHERE x = ?', + $q['sql'] + ); + $this->assertEquals([1, 2, 3], $q['params']); + } + + /** + * Test clearAll() clears subqueries, unions, HAVING and DISTINCT state + */ + public function testClearAllClearsSubqueriesUnionsHaving(): void + { + $query = Builder::table('users') + ->distinct() + ->selectSubquery( + Builder::table('orders')->selectCount('id')->where(['status' => 'paid']), + 'order_count' + ) + ->fromSubquery(Builder::table('users')->select(['id'])->where(['active' => true]), 'u') + ->where(['role' => 'admin']) + ->groupBy('role') + ->having(['order_count' => ['>', 2]]) + ->union(Builder::table('users')->select(['id'])->where(['archived' => true])); + + $query->clearAll(); + $q = $query->build(); + + $this->assertEquals('SELECT * FROM users', $q['sql']); + $this->assertEmpty($q['params']); + } + + /** + * Test build(true) resets subqueries, unions and HAVING state + */ + public function testBuildResetClearsSubqueriesUnionsHaving(): void + { + $query = Builder::table('users') + ->distinct() + ->selectSubquery( + Builder::table('orders')->selectCount('id')->where(['status' => 'paid']), + 'order_count' + ) + ->where(['role' => 'admin']) + ->union(Builder::table('users')->select(['id'])->where(['archived' => true])); + + $query->build(true); + $q = $query->build(); + + $this->assertEquals('SELECT * FROM users', $q['sql']); + $this->assertEmpty($q['params']); + } + + /** + * Test like() accepts a numeric value and casts it to string + */ + public function testLikeWithNumericValue(): void + { + $q = Builder::table('products')->like('code', 123)->build(); + + $this->assertEquals("SELECT * FROM products WHERE code LIKE ? ESCAPE '!'", $q['sql']); + $this->assertEquals(['%123%'], $q['params']); + } + + /** + * Test every generated query has exactly one bound parameter per placeholder + */ + public function testPlaceholderCountMatchesParamCount(): void + { + $queries = [ + 'select conditions' => Builder::table('users') + ->where(['status' => 'active']) + ->orWhere(['role' => 'admin', 'plan' => 'pro']) + ->whereIn('id', [1, 2, 3]) + ->like('name', 'john') + ->groupBy('role') + ->having(['cnt' => ['>', 1]]) + ->orderBy('id', 'DESC') + ->limit(5) + ->build(), + 'select subqueries' => Builder::table('users') + ->selectSubquery( + Builder::table('orders')->selectCount('id')->where(['s' => 1]), + 'c' + ) + ->fromSubquery(Builder::table('users')->where(['a' => 1]), 'u') + ->where(['b' => 2]) + ->having(['c' => ['>', 3]]) + ->build(), + 'unions' => Builder::table('a') + ->where(['x' => 1]) + ->union(Builder::table('a')->where(['x' => 2])) + ->unionAll(Builder::table('a')->where(['x' => 3])) + ->build(), + 'count' => Builder::table('orders') + ->fromSubquery(Builder::table('orders')->where(['a' => 1]), 'o') + ->count('id') + ->where(['b' => 2]) + ->having(['cnt' => ['>', 3]]) + ->build(), + 'update' => Builder::table('users') + ->update(['a' => 1, 'b' => Builder::raw('COALESCE(?, 0)', [9])]) + ->where(['id' => 7]) + ->build(), + 'delete' => Builder::table('users') + ->delete() + ->where(['id' => 7]) + ->like('name', 'x') + ->build(), + 'insert' => Builder::table('users') + ->insert(['a' => 1, 'b' => Builder::raw('COALESCE(?, 0)', [9])]) + ->build(), + 'insert batch' => Builder::table('users') + ->insertBatch([['a' => Builder::raw('COALESCE(?, 0)', [1])]]) + ->build(), + 'upsert batch' => Builder::table('stats') + ->upsertBatch([ + ['id' => Builder::raw('?', [1]), 'v' => Builder::raw('VALUES(v) + ?', [2])], + ], ['id']) + ->build(), + 'update batch' => Builder::table('users') + ->updateBatch([ + ['id' => 1, 'name' => 'A'], + ['id' => 2, 'name' => 'B'], + ], 'id') + ->build(), + 'delete batch' => Builder::table('users')->deleteBatch('id', [1, 2])->build(), + 'nested groups' => Builder::table('users') + ->groupStart() + ->where(['a' => 1]) + ->orGroupStart() + ->where(['b' => 2]) + ->whereIn('c', [3, 4]) + ->groupEnd() + ->groupEnd() + ->like('d', 'e') + ->build(), + ]; + + foreach ($queries as $label => $q) { + $this->assertSame( + substr_count($q['sql'], '?'), + count($q['params']), + "Placeholder/param mismatch for: {$label} ({$q['sql']})" + ); + } + } + + /** + * Test like() leaves backslashes untouched (they are literal under ESCAPE '!') + */ + public function testLikePreservesBackslash(): void + { + $q = Builder::table('files')->like('path', 'a\\b', 'none')->build(); + + $this->assertEquals(['a\\b'], $q['params']); + } +} \ No newline at end of file diff --git a/tests/BuilderRawTest.php b/tests/BuilderRawTest.php index dfca8e9..7bbe009 100644 --- a/tests/BuilderRawTest.php +++ b/tests/BuilderRawTest.php @@ -197,4 +197,18 @@ public function testWithIdentifiersThrowsExceptionForInvalidIdentifier(): void ['col' => 'name; DROP TABLE users--'] ); } + + /** + * Test __toString() returns the raw value for string contexts + */ + public function testToStringReturnsRawValue(): void + { + $raw = new BuilderRaw('COALESCE(amount, ?)', [0]); + + $this->assertEquals('COALESCE(amount, ?)', (string) $raw); + $this->assertEquals( + 'a, b', + implode(', ', [new BuilderRaw('a'), new BuilderRaw('b')]) + ); + } } diff --git a/tests/QueryLoggerTest.php b/tests/QueryLoggerTest.php new file mode 100644 index 0000000..0c0c742 --- /dev/null +++ b/tests/QueryLoggerTest.php @@ -0,0 +1,89 @@ +assertSame(class_exists('Tracy\Debugger'), QueryLogger::isEnabled()); + } + + /** + * Test getMetrics() returns the default metric keys after a reset + */ + public function testGetMetricsShapeAfterReset(): void + { + $this->assertSame([ + 'total_queries' => 0, + 'select_queries' => 0, + 'insert_queries' => 0, + 'update_queries' => 0, + 'delete_queries' => 0, + 'count_queries' => 0, + ], QueryLogger::getMetrics()); + } + + /** + * Test logging a query records it and updates the metrics + */ + public function testLogRecordsQueryAndMetrics(): void + { + Builder::table('users')->where(['id' => 1])->build(); + + $queries = QueryLogger::getQueries(); + $this->assertCount(1, $queries); + $this->assertSame('select', $queries[0]['action']); + $this->assertSame('SELECT * FROM users WHERE id = ?', $queries[0]['output']['sql']); + $this->assertSame([1], $queries[0]['output']['params']); + $this->assertSame('users', $queries[0]['input']['table']); + $this->assertIsArray($queries[0]['input']['where']); + $this->assertSame(['id = ?'], $queries[0]['input']['where']); + + $metrics = QueryLogger::getMetrics(); + $this->assertSame(1, $metrics['total_queries']); + $this->assertSame(1, $metrics['select_queries']); + } + + /** + * Test logging a new action creates its metric key + */ + public function testLogCreatesMetricKeyForNewAction(): void + { + Builder::table('users')->insertBatch([['name' => 'Alice']])->build(); + + $metrics = QueryLogger::getMetrics(); + $this->assertSame(1, $metrics['total_queries']); + $this->assertSame(1, $metrics['insertBatch_queries']); + } + + /** + * Test reset() clears queries and metrics + */ + public function testResetClearsQueriesAndMetrics(): void + { + Builder::table('users')->build(); + + QueryLogger::reset(); + + $this->assertSame([], QueryLogger::getQueries()); + $this->assertSame(0, QueryLogger::getMetrics()['total_queries']); + } +} diff --git a/tests/QueryPanelTest.php b/tests/QueryPanelTest.php new file mode 100644 index 0000000..31a1d76 --- /dev/null +++ b/tests/QueryPanelTest.php @@ -0,0 +1,86 @@ +assertSame('', $panel->getTab()); + return; + } + + $this->assertStringContainsString('SQL: 0', $panel->getTab()); + + Builder::table('users')->where(['id' => 1])->build(); + + $this->assertStringContainsString('SQL: 1', $panel->getTab()); + } + + /** + * Test getPanel() reports when logging is disabled + */ + public function testGetPanelWhenDisabled(): void + { + if (QueryLogger::isEnabled()) { + $this->markTestSkipped('Tracy is available, so logging is enabled'); + } + + $this->assertStringContainsString('Logger not enabled', (new QueryPanel())->getPanel()); + } + + /** + * Test getPanel() renders summary cards, SQL, params and detail rows + */ + public function testGetPanelRendersLoggedQueries(): void + { + if (!QueryLogger::isEnabled()) { + $this->markTestSkipped('Tracy is not available, so logging is disabled'); + } + + Builder::table('users', 'u') + ->select(['u.id', 'u.name']) + ->innerJoin('posts', 'u.id = p.user_id', 'p') + ->where(['u.status' => 'active']) + ->groupBy('u.id') + ->orderBy('u.name DESC') + ->limit(10) + ->build(); + + Builder::table('users')->insert(['name' => 'Alice'])->build(); + + $html = (new QueryPanel())->getPanel(); + + $this->assertStringContainsString('EasyQuery - SQL Builder', $html); + $this->assertStringContainsString('Total Queries', $html); + $this->assertStringContainsString('SELECT u.id, u.name FROM users AS u', $html); + $this->assertStringContainsString('active', $html); + $this->assertStringContainsString('Joins:', $html); + $this->assertStringContainsString('Group By:', $html); + $this->assertStringContainsString('Order By:', $html); + $this->assertStringContainsString('Limit:', $html); + $this->assertStringContainsString('Set Data:', $html); + $this->assertStringContainsString('Select:', $html); + } +}