From 17aff3f1ba5e3d8b62812484e4587343ba47ad6d Mon Sep 17 00:00:00 2001 From: Jakub Trmota Date: Wed, 17 Jan 2024 00:54:10 +0100 Subject: [PATCH] Fluent: add MERGE --- docs/fluent.md | 153 +++++++++++++++- phpcs.xml | 4 + phpstan.neon | 2 +- src/Fluent/Connection.php | 28 +-- .../Exceptions/QueryBuilderException.php | 34 ++-- src/Fluent/Exceptions/QueryException.php | 7 + src/Fluent/Query.php | 85 ++++----- src/Fluent/QueryBuilder.php | 101 +++++++++-- src/Fluent/Sql.php | 20 ++- tests/Integration/QueryExecuteFetchTest.php | 48 ----- tests/Unit/FluentConnectionTest.php | 60 +++++++ tests/Unit/FluentQueryTest.php | 165 ++++++++++++++++-- 12 files changed, 551 insertions(+), 156 deletions(-) diff --git a/docs/fluent.md b/docs/fluent.md index 455e203..333b79c 100644 --- a/docs/fluent.md +++ b/docs/fluent.md @@ -92,7 +92,7 @@ Every query is `SELECT` at first, until you call `->insert(...)`, `->update(...) - `whereAnd(array $conditions = []): Complex` (or `whereOr(...)` / `havingAnd(...)` / `havingOr()`) - with these methods, you can generate condition groups. Ale provided conditions are connected with logic `AND` for `whereAnd()` and `havingAnd()` and with logic `OR` for `whereOr()` and `havingOr()`. All these methods return `Complex` object (more about this later). `$conditions` items can be simple `string`, another `array` (this is a little bit magic - this works as `where()`/`having()` method - first item in this `array` is conditions and next items are parameters), `Complex` or `Db\Sql`. -- `groupBy(string ...$columns)`: - generates `GROUP BY` statement, one or more `string` parameters must be provided. +- `groupBy(string ...$columns)` - generates `GROUP BY` statement, one or more `string` parameters must be provided. - `orderBy(...$columns): Query` - generates `ORDER BY` statement, one or more parameters must be provided. Parameter can be simple `string`, another `Query` or `Db\Sql`. @@ -116,7 +116,7 @@ Every query is `SELECT` at first, until you call `->insert(...)`, `->update(...) - `rows(array $rows)` - this method can be used to insert multiple rows in one query. `$rows` is an `array` of arrays. Each array is one row (the same as for the `values()` method). All rows must have the same columns. Method can be called multiple and all rows are merged. -- `update(?string $table = NULL, ?string $alias = NULL)`: — set query for update. If the main table is not set, you must set it or rewrite with the `$table` parameter. `$alias` can be provided, when you want to use `UPDATE ... FROM ...`. +- `update(?string $table = NULL, ?string $alias = NULL)` - set query for update. If the main table is not set, you must set it or rewrite with the `$table` parameter. `$alias` can be provided, when you want to use `UPDATE ... FROM ...`. - `set(array $data)` - sets data to update. Rules for the data are the same as for the `values()` method. @@ -128,6 +128,18 @@ Every query is `SELECT` at first, until you call `->insert(...)`, `->update(...) - `returning(array $returning)` - generates `RETURNING` statement for `INSERT`, `UPDATE` or `DELETE`. Syntax for `$returning` is the same as for the `select()` method. +- `merge(?string $into = NULL, ?string $alias = NULL)` - set query for merge. If the main table is not set, you must set it or rewrite with the `$into` parameter. `$alias` can be provided. + + +- `using($dataSource, ?string $alias = NULL, $onCondition = NULL)` - set a data source for a merge command. `$dataSource` can be simple string, `Db\Sql\Query` or `Fluent\Query`. `$onCondition` can be simple `string` or other `Complex` or `Db\Sql`. `Db\Sql` can be used for some complex expression, where you need to use `?` and parameters. On condition can be added or extended with the `on()` method. + + +- `whenMatched($then, $onCondition = NULL)` - add matched branch to a merge command. `$then` is simple string or `Db\Sql` and `$onCondition` can be simple `string` or other `Complex` or `Db\Sql`. `Db\Sql` can be used for some complex expression, where you need to use `?` and parameters. + + +- `whenNotMatched($then, $onCondition = NULL)` - add not matched branch to a merge command. `$then` is simple string or `Db\Sql` and `$onCondition` can be simple `string` or other `Complex` or `Db\Sql`. `Db\Sql` can be used for some complex expression, where you need to use `?` and parameters. + + - `truncate(?string $table = NULL)` - truncates table. If the main table is not set, you must provide/rewrite it with the `$table` parameter. @@ -422,6 +434,143 @@ $deleteRows = $connection dump($deleteRows); // (integer) 1 ``` +### Merge + +Oficial docs: https://www.postgresql.org/docs/current/sql-merge.html + +`MERGE` command was added in the PostgreSQL v15. You can use it to conditionally insert, update, or delete rows of a table. + +Simple use can look like: + +```php +$query = $connection + ->merge('customer_account', 'ca') + ->using('recent_transactions', 't', 't.customer_id = ca.customer_id') + ->whenMatched('UPDATE SET balance = balance + transaction_value') + ->whenNotMatched('INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)'); + +dump($query); // (Query) MERGE INTO customer_account AS ca USING recent_transactions AS t ON t.customer_id = ca.customer_id WHEN MATCHED THEN UPDATE SET balance = balance + transaction_value WHEN NOT MATCHED THEN INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value) +``` + +The `ON` condition can be used with the `on()` method: + +```php +$query = $connection + ->merge('customer_account', 'ca') + ->using('recent_transactions', 't') + ->on('t', 't.customer_id = ca.customer_id') + ->whenMatched('UPDATE SET balance = balance + transaction_value') + ->whenNotMatched('INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)'); + +dump($query); // (Query) MERGE INTO customer_account AS ca USING recent_transactions AS t ON t.customer_id = ca.customer_id WHEN MATCHED THEN UPDATE SET balance = balance + transaction_value WHEN NOT MATCHED THEN INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value) +``` + +The `WHEN (NOT) MATCHED` branches can have conditions: + +```php +$query = $connection + ->merge('wines', 'w') + ->using('wine_stock_changes', 's', 's.winename = w.winename') + ->whenNotMatched('INSERT VALUES(s.winename, s.stock_delta)', 's.stock_delta > 0') + ->whenMatched('UPDATE SET stock = w.stock + s.stock_delta', Forrest79\PhPgSql\Fluent\Complex::createAnd()->add('w.stock + s.stock_delta > ?', 0)) + ->whenMatched('DELETE'); + +dump($query); // (Query) MERGE INTO wines AS w USING wine_stock_changes AS s ON s.winename = w.winename WHEN NOT MATCHED AND s.stock_delta > 0 THEN INSERT VALUES(s.winename, s.stock_delta) WHEN MATCHED AND w.stock + s.stock_delta > $1 THEN UPDATE SET stock = w.stock + s.stock_delta WHEN MATCHED THEN DELETE [Params: (array) [0]] +``` + +@todo DO NOTHING + +#### Upsert + +The `MERGE` command can be used for simply upsert (perform UPDATE and if recond not exists yet perform INSERT). The query could look like this: + +```sql +MERGE INTO users AS u + USING (VALUES ('Bob', FALSE)) AS source (nick, active) ON u.nick = source.nick + WHEN MATCHED THEN + UPDATE SET active = source.active + WHEN NOT MATCHED THEN + INSERT (nick, active) VALUES (source.nick, source.active); +``` + +Unfortunately, this can't be used simply with the parameters: + +```sql +MERGE INTO users AS u + USING (VALUES (?, ?)) AS source (nick, active) ON u.nick = source.nick + WHEN MATCHED THEN + UPDATE SET active = source.active + WHEN NOT MATCHED THEN + INSERT (nick, active) VALUES (source.nick, source.active); +``` + +Because DB needs to know the parameter types and all parameters are treated as text. You must use a concrete cast like this: + +```sql +MERGE INTO users AS u + USING (VALUES (?, ?::boolean)) AS source (nick, active) ON u.nick = source.nick + WHEN MATCHED THEN + UPDATE SET active = source.active + WHEN NOT MATCHED THEN + INSERT (nick, active) VALUES (source.nick, source.active); +``` + +For a query like this, it's not a problem. But when you want to prepare a common method for more tables and parameters, you must use a little trick. + +```sql +MERGE INTO users AS u + USING (SELECT 1) AS x ON u.nick = $1 + WHEN MATCHED THEN + UPDATE SET active = $2 + WHEN NOT MATCHED THEN + INSERT (nick, active) VALUES ($1, $2); +``` +And this is how this could be prepared with the fluent interface: + +```php +$updateRow = $connection + ->merge('users', 'u') + ->using('(SELECT 1)', 'x', 'u.nick = $1') + ->whenMatched('UPDATE SET active = $2') + ->whenNotMatched(Forrest79\PhPgSql\Db\Sql\Expression::create('INSERT (nick, active) VALUES ($1, $2)', 'Bob', 'f')) + ->getAffectedRows(); + +dump($updateRow); // (integer) 1 + +$updatedRows = $connection->query('SELECT nick, active FROM users WHERE nick = ?', 'Bob')->fetchAll(); + +table($updatedRows); +/** +--------------------------------- +| nick | active | +|===============================| +| (string) 'Bob' | (bool) FALSE | +--------------------------------- +*/ + +$insertRow = $connection + ->merge('users', 'u') + ->using('(SELECT 1)', 'x', 'u.nick = $1') + ->whenMatched('UPDATE SET active = $2') + ->whenNotMatched(Forrest79\PhPgSql\Db\Sql\Expression::create('INSERT (nick, active) VALUES ($1, $2)', 'Margaret', 't')) + ->getAffectedRows(); + +dump($updateRow); // (integer) 1 + +$insertedRows = $connection->query('SELECT nick, active FROM users WHERE nick = ?', 'Margaret')->fetchAll(); + +table($insertedRows); +/** +------------------------------------- +| nick | active | +|===================================| +| (string) 'Margaret' | (bool) TRUE | +------------------------------------- +*/ +``` + +> IMPORTANT: with this trick, when `$1`, `$2`, ... is used instead of `?`, `?`, ... we must use bool parameters as `t` and `f`. Automatic bool parameters replacing remove `?` from the query and bool parameter from the parameter list and put string `'TRUE'` or `'FALSE'` right into the query. When `$1` is used, bool parameter is still removed from the list, but the query is untouched, so there will be fewer parameters than `$1`, `$2`, ... in the query. + ### Truncate Just with table name: diff --git a/phpcs.xml b/phpcs.xml index eda39b2..69b4505 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -19,6 +19,10 @@ src/Fluent/QueryBuilder.php + + src/Fluent/Sql.php + + src/Fluent/Exceptions/ComplexException.php tests/Integration/DocsTest.php diff --git a/phpstan.neon b/phpstan.neon index 8abcfb1..1f4b046 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -156,7 +156,7 @@ parameters: # === PHPStan imperfection === - - message: "#^Property Forrest79\\\\PhPgSql\\\\Fluent\\\\Query::\\$params \\(array\\{select: array\\, distinct: bool, tables: array\\, table\\-types: array\\{main: string\\|null, from: list\\, joins: list\\\\}, join\\-conditions: array\\, lateral\\-tables: array\\, where: Forrest79\\\\PhPgSql\\\\Fluent\\\\Complex\\|null, groupBy: array\\, \\.\\.\\.\\}\\) does not accept .+\\.$#" + message: "#^Property Forrest79\\\\PhPgSql\\\\Fluent\\\\Query::\\$params \\(array\\{select: array\\, distinct: bool, tables: array\\, table\\-types: array\\{main: string\\|null, from: list\\, joins: list\\, using: string\\|null\\}, on\\-conditions: array\\, lateral\\-tables: array\\, where: Forrest79\\\\PhPgSql\\\\Fluent\\\\Complex\\|null, groupBy: array\\, \\.\\.\\.\\}\\) does not accept .+\\.$#" path: src/Fluent/Query.php count: 5 diff --git a/src/Fluent/Connection.php b/src/Fluent/Connection.php index d9f5176..c3d6c32 100644 --- a/src/Fluent/Connection.php +++ b/src/Fluent/Connection.php @@ -393,57 +393,59 @@ public function delete(?string $from = NULL, ?string $alias = NULL): Query /** + * @param array $returning * @return QueryExecute * @throws Exceptions\QueryException */ - public function merge(?string $into = NULL, ?string $alias = NULL): Query + public function returning(array $returning): Query { - return $this->createQuery()->merge($into, $alias); + return $this->createQuery()->returning($returning); } /** - * @param string|Query|Db\Sql $dataSource values, table or query - * @param string|Complex|Db\Sql|NULL $onCondition * @return QueryExecute * @throws Exceptions\QueryException */ - public function using($dataSource, ?string $alias = NULL, $onCondition = NULL): Query + public function merge(?string $into = NULL, ?string $alias = NULL): Query { - return $this->createQuery()->using($dataSource, $alias, $onCondition); + return $this->createQuery()->merge($into, $alias); } /** + * @param string|Query|Db\Sql $dataSource values, table or query * @param string|Complex|Db\Sql|NULL $onCondition * @return QueryExecute * @throws Exceptions\QueryException */ - public function whenMatched(string $then, $onCondition = NULL): Query + public function using($dataSource, ?string $alias = NULL, $onCondition = NULL): Query { - return $this->createQuery()->whenMatched($then, $onCondition); + return $this->createQuery()->using($dataSource, $alias, $onCondition); } /** + * @param string|Db\Sql $then * @param string|Complex|Db\Sql|NULL $onCondition * @return QueryExecute * @throws Exceptions\QueryException */ - public function whenNotMatched(string $then, $onCondition = NULL): Query + public function whenMatched($then, $onCondition = NULL): Query { - return $this->createQuery()->whenNotMatched($then, $onCondition); + return $this->createQuery()->whenMatched($then, $onCondition); } /** - * @param array $returning + * @param string|Db\Sql $then + * @param string|Complex|Db\Sql|NULL $onCondition * @return QueryExecute * @throws Exceptions\QueryException */ - public function returning(array $returning): Query + public function whenNotMatched($then, $onCondition = NULL): Query { - return $this->createQuery()->returning($returning); + return $this->createQuery()->whenNotMatched($then, $onCondition); } diff --git a/src/Fluent/Exceptions/QueryBuilderException.php b/src/Fluent/Exceptions/QueryBuilderException.php index daddbc2..d6bd718 100644 --- a/src/Fluent/Exceptions/QueryBuilderException.php +++ b/src/Fluent/Exceptions/QueryBuilderException.php @@ -6,15 +6,16 @@ class QueryBuilderException extends Exception { public const BAD_QUERY_TYPE = 1; public const NO_COLUMNS_TO_SELECT = 2; - public const NO_JOIN_CONDITIONS = 3; + public const NO_ON_CONDITION = 3; public const NO_DATA_TO_INSERT = 4; public const NO_DATA_TO_UPDATE = 5; public const DATA_CANT_CONTAIN_ARRAY = 6; public const NO_MAIN_TABLE = 7; public const BAD_PARAMS_COUNT = 8; - public const BAD_PARAM = 9; - public const NO_CORRESPONDING_TABLE = 10; - public const SELECT_ALL_COLUMNS_CANT_BE_COMBINED_WITH_CONCRETE_COLUMN_FOR_INSERT_SELECT_WITH_COLUMN_DETECTION = 11; + public const NO_CORRESPONDING_TABLE = 9; + public const SELECT_ALL_COLUMNS_CANT_BE_COMBINED_WITH_CONCRETE_COLUMN_FOR_INSERT_SELECT_WITH_COLUMN_DETECTION = 10; + public const NO_USING = 11; + public const NO_WHEN = 12; public static function badQueryType(string $type): self @@ -29,9 +30,9 @@ public static function noColumnsToSelect(): self } - public static function noJoinConditions(string $alias): self + public static function noOnCondition(string $alias): self { - return new self(\sprintf('No join conditions for table alias \'%s\'.', $alias), self::NO_JOIN_CONDITIONS); + return new self(\sprintf('There is no conditions for ON for table alias \'%s\'.', $alias), self::NO_ON_CONDITION); } @@ -65,15 +66,6 @@ public static function badParamsCount(string $condition, int $expected, int $act } - /** - * @param array $validValues - */ - public static function badParam(string $param, string $value, array $validValues): self - { - return new self(\sprintf('Bad param \'%s\' with value \'%s\'. Valid values are \'%s\'.', $param, $value, \implode('\', \'', $validValues)), self::BAD_PARAM); - } - - /** * @param array $aliases */ @@ -88,4 +80,16 @@ public static function selectAllColumnsCantBeCombinedWithConcreteColumnForInsert return new self('You can\'t use \'SELECT *\' and also some concrete column for INSERT - SELECT with column detection.', self::SELECT_ALL_COLUMNS_CANT_BE_COMBINED_WITH_CONCRETE_COLUMN_FOR_INSERT_SELECT_WITH_COLUMN_DETECTION); } + + public static function noUsing(): self + { + return new self('No USING for MERGE.', self::NO_USING); + } + + + public static function noWhen(): self + { + return new self('No WHEN for MERGE.', self::NO_WHEN); + } + } diff --git a/src/Fluent/Exceptions/QueryException.php b/src/Fluent/Exceptions/QueryException.php index b92bf6b..f9964c0 100644 --- a/src/Fluent/Exceptions/QueryException.php +++ b/src/Fluent/Exceptions/QueryException.php @@ -11,6 +11,7 @@ class QueryException extends Exception public const PARAM_MUST_BE_SCALAR_OR_ENUM_OR_EXPRESSION = 5; public const CANT_UPDATE_QUERY_AFTER_EXECUTE = 6; public const YOU_MUST_EXECUTE_QUERY_BEFORE_THAT = 7; + public const ONLY_ONE_USING = 8; public static function onlyOneMainTable(): self @@ -57,4 +58,10 @@ public static function youMustExecuteQueryBeforeThat(): self return new self('You must execute query before that', self::YOU_MUST_EXECUTE_QUERY_BEFORE_THAT); } + + public static function onlyOneUsing(): self + { + return new self('USING can be set only once.', self::ONLY_ONE_USING); + } + } diff --git a/src/Fluent/Query.php b/src/Fluent/Query.php index 32cc79e..85535fd 100644 --- a/src/Fluent/Query.php +++ b/src/Fluent/Query.php @@ -20,7 +20,7 @@ class Query implements Sql public const PARAM_DISTINCT = 'distinct'; public const PARAM_TABLES = 'tables'; public const PARAM_TABLE_TYPES = 'table-types'; - public const PARAM_JOIN_CONDITIONS = 'join-conditions'; + public const PARAM_ON_CONDITIONS = 'on-conditions'; public const PARAM_LATERAL_TABLES = 'lateral-tables'; public const PARAM_WHERE = 'where'; public const PARAM_GROUPBY = 'groupBy'; @@ -41,6 +41,7 @@ class Query implements Sql public const TABLE_TYPE_MAIN = 'main'; public const TABLE_TYPE_FROM = 'from'; public const TABLE_TYPE_JOINS = 'joins'; + public const TABLE_TYPE_USING = 'using'; private const JOIN_INNER = 'INNER JOIN'; private const JOIN_LEFT_OUTER = 'LEFT OUTER JOIN'; @@ -53,9 +54,6 @@ class Query implements Sql private const COMBINE_INTERSECT = 'INTERSECT'; private const COMBINE_EXCEPT = 'EXCEPT'; - public const MERGE_USING_DATA_SOURCE = 'using-data-source'; - public const MERGE_USING_ALIAS = 'using-alias'; - public const MERGE_WHEN = 'when'; public const MERGE_WHEN_MATCHED = 'when-matched'; public const MERGE_WHEN_NOT_MATCHED = 'when-not-matched'; @@ -72,8 +70,9 @@ class Query implements Sql self::TABLE_TYPE_MAIN => NULL, self::TABLE_TYPE_FROM => [], self::TABLE_TYPE_JOINS => [], + self::TABLE_TYPE_USING => NULL, ], - self::PARAM_JOIN_CONDITIONS => [], + self::PARAM_ON_CONDITIONS => [], self::PARAM_LATERAL_TABLES => [], self::PARAM_WHERE => NULL, self::PARAM_GROUPBY => [], @@ -86,11 +85,7 @@ class Query implements Sql self::PARAM_RETURNING => [], self::PARAM_DATA => [], self::PARAM_ROWS => [], - self::PARAM_MERGE => [ - self::MERGE_USING_DATA_SOURCE => NULL, - self::MERGE_USING_ALIAS => [], - self::MERGE_WHEN => [], - ], + self::PARAM_MERGE => [], self::PARAM_WITH => [ self::WITH_QUERIES => [], self::WITH_QUERIES_SUFFIX => [], @@ -302,8 +297,8 @@ private function addTable(string $type, $name, ?string $alias, $onCondition = NU $this->checkAlias($name, $alias); - if (($type === self::TABLE_TYPE_MAIN) && ($this->params[self::PARAM_TABLE_TYPES][self::TABLE_TYPE_MAIN] !== NULL)) { - throw Exceptions\QueryException::onlyOneMainTable(); + if (in_array($type, [self::TABLE_TYPE_MAIN, self::TABLE_TYPE_USING], TRUE) && ($this->params[self::PARAM_TABLE_TYPES][$type] !== NULL)) { + throw ($type === self::TABLE_TYPE_MAIN) ? Exceptions\QueryException::onlyOneMainTable() : Exceptions\QueryException::onlyOneUsing(); } if ($alias === NULL) { @@ -317,14 +312,14 @@ private function addTable(string $type, $name, ?string $alias, $onCondition = NU $this->params[self::PARAM_TABLES][$alias] = [$name, $type]; - if ($type === self::TABLE_TYPE_MAIN) { + if (in_array($type, [self::TABLE_TYPE_MAIN, self::TABLE_TYPE_USING], TRUE)) { $this->params[self::PARAM_TABLE_TYPES][$type] = $alias; } else { $this->params[self::PARAM_TABLE_TYPES][$type === self::TABLE_TYPE_FROM ? $type : self::TABLE_TYPE_JOINS][] = $alias; } if ($onCondition !== NULL) { - $this->getComplexParam(self::PARAM_JOIN_CONDITIONS, $alias)->add($onCondition); + $this->getComplexParam(self::PARAM_ON_CONDITIONS, $alias)->add($onCondition); } return $this; @@ -340,7 +335,7 @@ private function addTable(string $type, $name, ?string $alias, $onCondition = NU public function on(string $alias, $condition, ...$params): self { $this->resetQuery(); - $this->getComplexParam(self::PARAM_JOIN_CONDITIONS, $alias)->add($condition, ...$params); + $this->getComplexParam(self::PARAM_ON_CONDITIONS, $alias)->add($condition, ...$params); return $this; } @@ -450,12 +445,12 @@ public function havingOr(array $conditions = []): Complex private function getComplexParam(string $param, ?string $alias = NULL): Complex { - if ($param === self::PARAM_JOIN_CONDITIONS) { + if ($param === self::PARAM_ON_CONDITIONS) { if (!isset($this->params[$param][$alias])) { $this->params[$param][$alias] = Complex::createAnd(); } return $this->params[$param][$alias]; - } elseif (($param === self::PARAM_WHERE) || ($param === self::PARAM_HAVING)) { + } else if (($param === self::PARAM_WHERE) || ($param === self::PARAM_HAVING)) { if ($this->params[$param] === NULL) { $this->params[$param] = Complex::createAnd(); } @@ -655,6 +650,19 @@ public function delete(?string $from = NULL, ?string $alias = NULL): self } + /** + * @param array $returning + * @return static + * @throws Exceptions\QueryException + */ + public function returning(array $returning): self + { + $this->resetQuery(); + $this->params[self::PARAM_RETURNING] = \array_merge($this->params[self::PARAM_RETURNING], $returning); + return $this; + } + + /** * @return static * @throws Exceptions\QueryException @@ -663,7 +671,7 @@ public function merge(?string $into = NULL, ?string $alias = NULL): self { $this->resetQuery(); - $this->queryType = self::QUERY_DELETE; + $this->queryType = self::QUERY_MERGE; if ($into !== NULL) { $this->table($into, $alias); @@ -681,49 +689,46 @@ public function merge(?string $into = NULL, ?string $alias = NULL): self */ public function using($dataSource, ?string $alias = NULL, $onCondition = NULL): self { - $this->resetQuery(); - $this->queryType = self::QUERY_MERGE; - $this->params[self::MERGE_USING_DATA_SOURCE] = $dataSource; - $this->params[self::MERGE_USING_ALIAS] = $alias; - return $this; + return $this->addTable(self::TABLE_TYPE_USING, $dataSource, $alias, $onCondition); } /** + * @param string|Db\Sql $then * @param string|Complex|Db\Sql|NULL $onCondition * @return static * @throws Exceptions\QueryException */ - public function whenMatched(string $then, $onCondition = NULL): self + public function whenMatched($then, $onCondition = NULL): self { $this->resetQuery(); - $this->params[self::MERGE_WHEN][] = [self::MERGE_WHEN_MATCHED, $then, $onCondition]; + + $this->params[self::PARAM_MERGE][] = [ + self::MERGE_WHEN_MATCHED, + $then, + $onCondition === NULL ? NULL : Complex::createAnd()->add($onCondition), + ]; + return $this; } /** + * @param string|Db\Sql $then * @param string|Complex|Db\Sql|NULL $onCondition * @return static * @throws Exceptions\QueryException */ - public function whenNotMatched(string $then, $onCondition = NULL): self + public function whenNotMatched($then, $onCondition = NULL): self { $this->resetQuery(); - $this->params[self::MERGE_WHEN][] = [self::MERGE_WHEN_NOT_MATCHED, $then, $onCondition]; - return $this; - } + $this->params[self::PARAM_MERGE][] = [ + self::MERGE_WHEN_NOT_MATCHED, + $then, + $onCondition === NULL ? NULL : Complex::createAnd()->add($onCondition), + ]; - /** - * @param array $returning - * @return static - * @throws Exceptions\QueryException - */ - public function returning(array $returning): self - { - $this->resetQuery(); - $this->params[self::PARAM_RETURNING] = \array_merge($this->params[self::PARAM_RETURNING], $returning); return $this; } @@ -888,8 +893,8 @@ public function __clone() { $this->resetQuery(); - foreach ($this->params[self::PARAM_JOIN_CONDITIONS] as $alias => $joinCondition) { - $this->params[self::PARAM_JOIN_CONDITIONS][$alias] = clone $joinCondition; + foreach ($this->params[self::PARAM_ON_CONDITIONS] as $alias => $joinCondition) { + $this->params[self::PARAM_ON_CONDITIONS][$alias] = clone $joinCondition; } if ($this->params[self::PARAM_WHERE] !== NULL) { diff --git a/src/Fluent/QueryBuilder.php b/src/Fluent/QueryBuilder.php index 7a1144c..c8013a0 100644 --- a/src/Fluent/QueryBuilder.php +++ b/src/Fluent/QueryBuilder.php @@ -9,8 +9,8 @@ * select: array, * distinct: bool, * tables: array, - * table-types: array{main: string|NULL, from: list, joins: list}, - * join-conditions: array, + * table-types: array{main: string|NULL, from: list, joins: list, using: string|NULL}, + * on-conditions: array, * lateral-tables: array, * where: Complex|NULL, * groupBy: array, @@ -23,6 +23,7 @@ * returning: array, * data: array, * rows: array>, + * merge: list, * with: array{queries: array, queries-suffix: array, queries-not-materialized: array, recursive: bool}, * prefix: list>, * suffix: list> @@ -57,6 +58,8 @@ public function createSqlQuery(string $queryType, array $queryParams): Db\Sql\Qu $sql .= $this->createUpdate($queryParams, $params); } else if ($queryType === Query::QUERY_DELETE) { $sql .= $this->createDelete($queryParams, $params); + } else if ($queryType === Query::QUERY_MERGE) { + $sql .= $this->createMerge($queryParams, $params); } else if ($queryType === Query::QUERY_TRUNCATE) { $sql .= $this->createTruncate($queryParams) . $this->getPrefixSuffix($queryParams, Query::PARAM_SUFFIX, $params); } else { @@ -111,8 +114,7 @@ private function createInsert(array $queryParams, array &$params): string { ['table' => $mainTable, 'alias' => $mainTableAlias] = $this->getMainTableMetadata($queryParams); - $insert = 'INSERT ' . $this->processTable( - 'INTO', + $insert = 'INSERT INTO ' . $this->processTable( $mainTable, $mainTableAlias, FALSE, @@ -222,7 +224,6 @@ private function createUpdate(array $queryParams, array &$params): string } return 'UPDATE ' . $this->processTable( - NULL, $mainTable, $mainTableAlias, FALSE, @@ -245,8 +246,7 @@ private function createUpdate(array $queryParams, array &$params): string private function createDelete(array $queryParams, array &$params): string { ['table' => $mainTable, 'alias' => $mainTableAlias] = $this->getMainTableMetadata($queryParams); - return 'DELETE ' . $this->processTable( - 'FROM', + return 'DELETE FROM ' . $this->processTable( $mainTable, $mainTableAlias, FALSE, @@ -258,6 +258,73 @@ private function createDelete(array $queryParams, array &$params): string } + /** + * @param array $queryParams + * @param list $params + * @throws Exceptions\QueryBuilderException + * @phpstan-param QueryParams $queryParams + */ + private function createMerge(array $queryParams, array &$params): string + { + ['table' => $mainTable, 'alias' => $mainTableAlias] = $this->getMainTableMetadata($queryParams); + + $usingAlias = $queryParams[Query::PARAM_TABLE_TYPES][Query::TABLE_TYPE_USING]; + if ($usingAlias === NULL) { + throw Exceptions\QueryBuilderException::noUsing(); + } + + if (!isset($queryParams[Query::PARAM_ON_CONDITIONS][$usingAlias])) { + throw Exceptions\QueryBuilderException::noOnCondition($usingAlias); + } + + if ($queryParams[Query::PARAM_MERGE] === []) { + throw Exceptions\QueryBuilderException::noWhen(); + } + + $merge = 'MERGE INTO ' . $this->processTable( + $mainTable, + $mainTableAlias, + FALSE, + $params + ) . ' USING ' . $this->processTable( + $queryParams[Query::PARAM_TABLES][$usingAlias][self::TABLE_NAME], + $usingAlias, + FALSE, + $params + ) . ' ON ' . $this->processComplex( + $queryParams[Query::PARAM_ON_CONDITIONS][$usingAlias], + $params + ); + + foreach ($queryParams[Query::PARAM_MERGE] as $when) { + [$type, $then, $onCondition] = $when; + + $merge .= ' WHEN'; + + if ($type === Query::MERGE_WHEN_NOT_MATCHED) { + $merge .= ' NOT'; + } else if ($type !== Query::MERGE_WHEN_MATCHED) { + throw new Exceptions\ShouldNotHappenException(\sprintf('Bad WHEN type \'%s\' for MERGE.', $type)); + } + + $merge .= ' MATCHED'; + + if ($onCondition !== NULL) { + $merge .= ' AND ' . $this->processComplex($onCondition, $params); + } + + if ($then instanceof Db\Sql) { + $params[] = $then; + $then = '?'; + } + + $merge .= ' THEN ' . $then; + } + + return $merge; + } + + /** * @param array $queryParams * @throws Exceptions\QueryBuilderException @@ -353,7 +420,6 @@ private function getFrom(array $queryParams, array &$params, bool $useMainTable $mainTableAlias = $queryParams[Query::PARAM_TABLE_TYPES][Query::TABLE_TYPE_MAIN]; if ($mainTableAlias !== NULL) { $from[] = $this->processTable( - NULL, $queryParams[Query::PARAM_TABLES][$mainTableAlias][self::TABLE_NAME], $mainTableAlias, isset($queryParams[Query::PARAM_LATERAL_TABLES][$mainTableAlias]), @@ -364,7 +430,6 @@ private function getFrom(array $queryParams, array &$params, bool $useMainTable foreach ($queryParams[Query::PARAM_TABLE_TYPES][Query::TABLE_TYPE_FROM] as $tableAlias) { $from[] = $this->processTable( - NULL, $queryParams[Query::PARAM_TABLES][$tableAlias][self::TABLE_NAME], $tableAlias, isset($queryParams[Query::PARAM_LATERAL_TABLES][$tableAlias]), @@ -387,7 +452,7 @@ private function getJoins(array $queryParams, array &$params): string $joins = []; $aliasesWithoutTables = array_diff( - array_keys($queryParams[Query::PARAM_JOIN_CONDITIONS]), + array_keys($queryParams[Query::PARAM_ON_CONDITIONS]), $queryParams[Query::PARAM_TABLE_TYPES][Query::TABLE_TYPE_JOINS] ); if ($aliasesWithoutTables !== []) { @@ -397,8 +462,7 @@ private function getJoins(array $queryParams, array &$params): string foreach ($queryParams[Query::PARAM_TABLE_TYPES][Query::TABLE_TYPE_JOINS] as $tableAlias) { $joinType = $queryParams[Query::PARAM_TABLES][$tableAlias][self::TABLE_TYPE]; - $table = $this->processTable( - $joinType, + $table = $joinType . ' ' . $this->processTable( $queryParams[Query::PARAM_TABLES][$tableAlias][self::TABLE_NAME], $tableAlias, isset($queryParams[Query::PARAM_LATERAL_TABLES][$tableAlias]), @@ -408,12 +472,12 @@ private function getJoins(array $queryParams, array &$params): string if ($joinType === Query::JOIN_CROSS) { $joins[] = $table; } else { - if (!isset($queryParams[Query::PARAM_JOIN_CONDITIONS][$tableAlias])) { - throw Exceptions\QueryBuilderException::noJoinConditions($tableAlias); + if (!isset($queryParams[Query::PARAM_ON_CONDITIONS][$tableAlias])) { + throw Exceptions\QueryBuilderException::noOnCondition($tableAlias); } $joins[] = $table . ' ON ' . $this->processComplex( - $queryParams[Query::PARAM_JOIN_CONDITIONS][$tableAlias], + $queryParams[Query::PARAM_ON_CONDITIONS][$tableAlias], $params ); } @@ -574,7 +638,7 @@ private function getPrefixSuffix(array $queryParams, string $type, array &$param return ' ' . \implode(' ', $processedItems); } - throw Exceptions\QueryBuilderException::badParam('$type', $type, [Query::PARAM_PREFIX, Query::PARAM_SUFFIX]); + throw new Exceptions\ShouldNotHappenException(\sprintf('Bad prefix/suffix type with value \'%s\'. Valid values are \'%s\'.', $type, \implode('\', \'', [Query::PARAM_PREFIX, Query::PARAM_SUFFIX]))); } @@ -655,11 +719,10 @@ final protected function getMainTableMetadata(array $queryParams): array /** - * @param string|NULL $type * @param string|Db\Sql|Query $table * @param list $params */ - private function processTable(?string $type, $table, string $alias, bool $isLateral, array &$params): string + private function processTable($table, string $alias, bool $isLateral, array &$params): string { if ($table instanceof Db\Sql) { $params[] = $table; @@ -677,7 +740,7 @@ private function processTable(?string $type, $table, string $alias, bool $isLate $table = 'LATERAL ' . $table; } - return (($type === NULL) ? '' : ($type . ' ')) . ($table === $alias ? $table : ($table . ' AS ' . $alias)); + return ($table === $alias) ? $table : ($table . ' AS ' . $alias); } diff --git a/src/Fluent/Sql.php b/src/Fluent/Sql.php index e9a4e1d..2030cc2 100644 --- a/src/Fluent/Sql.php +++ b/src/Fluent/Sql.php @@ -6,6 +6,8 @@ interface Sql { + const DO_NOTHING = 'DO NOTHING'; + /** * @param string|Query|Db\Sql $table @@ -207,6 +209,12 @@ function set(array $data): Query; function delete(?string $from = NULL, ?string $alias = NULL): Query; + /** + * @param array $returning + */ + function returning(array $returning): Query; + + function merge(?string $into = NULL, ?string $alias = NULL): Query; @@ -216,21 +224,19 @@ function merge(?string $into = NULL, ?string $alias = NULL): Query; */ function using($dataSource, ?string $alias = NULL, $onCondition = NULL): Query; - /** - * @param string|Complex|Db\Sql|NULL $onCondition - */ - function whenMatched(string $then, $onCondition = NULL): Query; /** + * @param string|Db\Sql $then * @param string|Complex|Db\Sql|NULL $onCondition */ - function whenNotMatched(string $then, $onCondition = NULL): Query; + function whenMatched($then, $onCondition = NULL): Query; /** - * @param array $returning + * @param string|Db\Sql $then + * @param string|Complex|Db\Sql|NULL $onCondition */ - function returning(array $returning): Query; + function whenNotMatched($then, $onCondition = NULL): Query; function truncate(?string $table = NULL): Query; diff --git a/tests/Integration/QueryExecuteFetchTest.php b/tests/Integration/QueryExecuteFetchTest.php index 581287a..f2d5d5f 100644 --- a/tests/Integration/QueryExecuteFetchTest.php +++ b/tests/Integration/QueryExecuteFetchTest.php @@ -15,54 +15,6 @@ final class QueryExecuteFetchTest extends TestCase { - public function testMerge(): void - { - $this->connection->query(' - CREATE TABLE users - ( - id serial NOT NULL, - nick text NOT NULL, - inserted_datetime timestamp with time zone DEFAULT now(), - active boolean NOT NULL DEFAULT FALSE, - age integer, - height_cm double precision, - phones bigint[], - PRIMARY KEY (id) - ); - '); - - $query = $this->connection->query(' - MERGE INTO users AS u - USING (SELECT 1) AS x ON u.nick = $1 - WHEN NOT MATCHED THEN - INSERT (nick, active) VALUES ($1, $2) - WHEN MATCHED THEN - UPDATE SET active = $2; - ', 'Bob', 'f'); - - Tester\Assert::same(1, $query->getAffectedRows()); - - $query->free(); - - Tester\Assert::same(FALSE, $this->connection->query('SELECT active FROM users')->fetchSingle()); - - $query = $this->connection->query(' - MERGE INTO users AS u - USING (SELECT 1) AS x ON u.nick = $1 - WHEN NOT MATCHED THEN - INSERT (nick, active) VALUES ($1, $2) - WHEN MATCHED THEN - UPDATE SET active = $2; - ', 'Bob', 't'); - - Tester\Assert::same(1, $query->getAffectedRows()); - - $query->free(); - - Tester\Assert::same(TRUE, $this->connection->query('SELECT active FROM users')->fetchSingle()); - } - - public function testFetch(): void { $this->connection->query(' diff --git a/tests/Unit/FluentConnectionTest.php b/tests/Unit/FluentConnectionTest.php index 115128a..c822da0 100644 --- a/tests/Unit/FluentConnectionTest.php +++ b/tests/Unit/FluentConnectionTest.php @@ -535,6 +535,66 @@ public function testReturning(): void } + public function testMerge(): void + { + $query = $this->fluentConnection + ->merge('customer_account', 'ca') + ->using('recent_transactions', 't', 't.customer_id = ca.customer_id') + ->whenMatched('UPDATE SET balance = balance + transaction_value') + ->whenNotMatched('INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)') + ->createSqlQuery() + ->createQuery(); + + Tester\Assert::same('MERGE INTO customer_account AS ca USING recent_transactions AS t ON t.customer_id = ca.customer_id WHEN MATCHED THEN UPDATE SET balance = balance + transaction_value WHEN NOT MATCHED THEN INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)', $query->getSql()); + Tester\Assert::same([], $query->getParams()); + } + + + public function testMergeUsing(): void + { + $query = $this->fluentConnection + ->using('recent_transactions', 't', 't.customer_id = ca.customer_id') + ->merge('customer_account', 'ca') + ->whenMatched('UPDATE SET balance = balance + transaction_value') + ->whenNotMatched('INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)') + ->createSqlQuery() + ->createQuery(); + + Tester\Assert::same('MERGE INTO customer_account AS ca USING recent_transactions AS t ON t.customer_id = ca.customer_id WHEN MATCHED THEN UPDATE SET balance = balance + transaction_value WHEN NOT MATCHED THEN INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)', $query->getSql()); + Tester\Assert::same([], $query->getParams()); + } + + + public function testMergeWhenMatched(): void + { + $query = $this->fluentConnection + ->whenMatched('UPDATE SET balance = balance + transaction_value') + ->merge('customer_account', 'ca') + ->using('recent_transactions', 't', 't.customer_id = ca.customer_id') + ->whenNotMatched('INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)') + ->createSqlQuery() + ->createQuery(); + + Tester\Assert::same('MERGE INTO customer_account AS ca USING recent_transactions AS t ON t.customer_id = ca.customer_id WHEN MATCHED THEN UPDATE SET balance = balance + transaction_value WHEN NOT MATCHED THEN INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)', $query->getSql()); + Tester\Assert::same([], $query->getParams()); + } + + + public function testMergeWhenNotMatched(): void + { + $query = $this->fluentConnection + ->whenNotMatched('INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)') + ->merge('customer_account', 'ca') + ->using('recent_transactions', 't', 't.customer_id = ca.customer_id') + ->whenMatched('UPDATE SET balance = balance + transaction_value') + ->createSqlQuery() + ->createQuery(); + + Tester\Assert::same('MERGE INTO customer_account AS ca USING recent_transactions AS t ON t.customer_id = ca.customer_id WHEN NOT MATCHED THEN INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value) WHEN MATCHED THEN UPDATE SET balance = balance + transaction_value', $query->getSql()); + Tester\Assert::same([], $query->getParams()); + } + + public function testTruncate(): void { $query = $this->fluentConnection diff --git a/tests/Unit/FluentQueryTest.php b/tests/Unit/FluentQueryTest.php index d66de07..1ce963f 100644 --- a/tests/Unit/FluentQueryTest.php +++ b/tests/Unit/FluentQueryTest.php @@ -580,7 +580,7 @@ public function testJoinNoOn(): void ->from('table', 't') ->join('another', 'x') ->createSqlQuery(); - }, Fluent\Exceptions\QueryBuilderException::class, NULL, Fluent\Exceptions\QueryBuilderException::NO_JOIN_CONDITIONS); + }, Fluent\Exceptions\QueryBuilderException::class, NULL, Fluent\Exceptions\QueryBuilderException::NO_ON_CONDITION); } @@ -974,14 +974,6 @@ public function testDelete(): void } - public function testTruncate(): void - { - $query = $this->query()->truncate('table')->createSqlQuery()->createQuery(); - Tester\Assert::same('TRUNCATE table', $query->getSql()); - Tester\Assert::same([], $query->getParams()); - } - - public function testReturningFluentQuery(): void { $query = $this->query() @@ -1010,6 +1002,157 @@ public function testReturningQuery(): void } + public function testMerge(): void + { + $query = $this->query() + ->merge('customer_account', 'ca') + ->using('recent_transactions', 't', 't.customer_id = ca.customer_id') + ->whenMatched('UPDATE SET balance = balance + transaction_value') + ->whenNotMatched('INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)') + ->createSqlQuery() + ->createQuery(); + + Tester\Assert::same('MERGE INTO customer_account AS ca USING recent_transactions AS t ON t.customer_id = ca.customer_id WHEN MATCHED THEN UPDATE SET balance = balance + transaction_value WHEN NOT MATCHED THEN INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)', $query->getSql()); + Tester\Assert::same([], $query->getParams()); + } + + + public function testMergeUsingFluentQuery(): void + { + $query = $this->query() + ->merge('customer_account', 'ca') + ->using($this->query()->select(['customer_id', 'transaction_value'])->from('recent_transactions')->where('customer_id > ?', 10), 't', 't.customer_id = ca.customer_id') + ->whenMatched('UPDATE SET balance = balance + transaction_value') + ->whenNotMatched('INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)') + ->createSqlQuery() + ->createQuery(); + + Tester\Assert::same('MERGE INTO customer_account AS ca USING (SELECT customer_id, transaction_value FROM recent_transactions WHERE customer_id > $1) AS t ON t.customer_id = ca.customer_id WHEN MATCHED THEN UPDATE SET balance = balance + transaction_value WHEN NOT MATCHED THEN INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)', $query->getSql()); + Tester\Assert::same([10], $query->getParams()); + } + + + public function testMergeUsingSql(): void + { + $query = $this->query() + ->merge('customer_account', 'ca') + ->using(new Db\Sql\Query('SELECT customer_id, transaction_value FROM recent_transactions WHERE customer_id > ?', [10]), 't', 't.customer_id = ca.customer_id') + ->whenMatched('UPDATE SET balance = balance + transaction_value') + ->whenNotMatched('INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)') + ->createSqlQuery() + ->createQuery(); + + Tester\Assert::same('MERGE INTO customer_account AS ca USING (SELECT customer_id, transaction_value FROM recent_transactions WHERE customer_id > $1) AS t ON t.customer_id = ca.customer_id WHEN MATCHED THEN UPDATE SET balance = balance + transaction_value WHEN NOT MATCHED THEN INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)', $query->getSql()); + Tester\Assert::same([10], $query->getParams()); + } + + + public function testMergeOnComplex(): void + { + $query = $this->query() + ->merge('customer_account', 'ca') + ->using('(SELECT customer_id, transaction_value FROM recent_transactions)', 't', Fluent\Complex::createAnd()->add('t.customer_id = ca.customer_id')->add('t.customer_id > ?', 10)) + ->whenMatched('UPDATE SET balance = balance + transaction_value') + ->whenNotMatched('INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)') + ->createSqlQuery() + ->createQuery(); + + Tester\Assert::same('MERGE INTO customer_account AS ca USING (SELECT customer_id, transaction_value FROM recent_transactions) AS t ON (t.customer_id = ca.customer_id) AND (t.customer_id > $1) WHEN MATCHED THEN UPDATE SET balance = balance + transaction_value WHEN NOT MATCHED THEN INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)', $query->getSql()); + Tester\Assert::same([10], $query->getParams()); + } + + + public function testMergeWhenOn(): void + { + $query = $this->query() + ->merge('wines', 'w') + ->using('wine_stock_changes', 's', 's.winename = w.winename') + ->whenNotMatched('INSERT VALUES(s.winename, s.stock_delta)', 's.stock_delta > 0') + ->whenMatched('UPDATE SET stock = w.stock + s.stock_delta', Fluent\Complex::createAnd()->add('w.stock + s.stock_delta > ?', 0)) + ->whenMatched('UPDATE SET stock = w.stock - s.stock_delta', Db\Sql\Expression::create('w.stock + s.stock_delta < ?', 0)) + ->whenMatched('DELETE') + ->createSqlQuery() + ->createQuery(); + + Tester\Assert::same('MERGE INTO wines AS w USING wine_stock_changes AS s ON s.winename = w.winename WHEN NOT MATCHED AND s.stock_delta > 0 THEN INSERT VALUES(s.winename, s.stock_delta) WHEN MATCHED AND w.stock + s.stock_delta > $1 THEN UPDATE SET stock = w.stock + s.stock_delta WHEN MATCHED AND w.stock + s.stock_delta < $2 THEN UPDATE SET stock = w.stock - s.stock_delta WHEN MATCHED THEN DELETE', $query->getSql()); + Tester\Assert::same([0, 0], $query->getParams()); + } + + + public function testMergeDoNothing(): void + { + $query = $this->query() + ->merge('wines', 'w') + ->using('wine_stock_changes', 's', 's.winename = w.winename') + ->whenNotMatched('INSERT VALUES(s.winename, s.stock_delta)') + ->whenMatched(Fluent\Query::DO_NOTHING) + ->createSqlQuery() + ->createQuery(); + + Tester\Assert::same('MERGE INTO wines AS w USING wine_stock_changes AS s ON s.winename = w.winename WHEN NOT MATCHED THEN INSERT VALUES(s.winename, s.stock_delta) WHEN MATCHED THEN DO NOTHING', $query->getSql()); + Tester\Assert::same([], $query->getParams()); + } + + + public function testMergeCommonUpsert(): void + { + $query = $this->query() + ->merge('wines', 'w') + ->using('(SELECT 1)', 's', 'w.winename = $1') + ->whenNotMatched(Db\Sql\Expression::create('INSERT (winename, balance) VALUES($1, $2)', 'Red wine', 10)) + ->whenMatched('UPDATE SET balance = $2') + ->createSqlQuery() + ->createQuery(); + + Tester\Assert::same('MERGE INTO wines AS w USING (SELECT 1) AS s ON w.winename = $1 WHEN NOT MATCHED THEN INSERT (winename, balance) VALUES($1, $2) WHEN MATCHED THEN UPDATE SET balance = $2', $query->getSql()); + Tester\Assert::same(['Red wine', 10], $query->getParams()); + } + + + public function testMergeNoUsing(): void + { + Tester\Assert::exception(function (): void { + $this->query() + ->merge('customer_account', 'ca') + ->whenMatched('UPDATE SET balance = balance + transaction_value') + ->whenNotMatched('INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)') + ->createSqlQuery(); + }, Fluent\Exceptions\QueryBuilderException::class, NULL, Fluent\Exceptions\QueryBuilderException::NO_USING); + } + + + public function testMergeNoOn(): void + { + Tester\Assert::exception(function (): void { + $this->query() + ->merge('customer_account', 'ca') + ->using('recent_transactions', 't') + ->whenMatched('UPDATE SET balance = balance + transaction_value') + ->whenNotMatched('INSERT (customer_id, balance) VALUES (t.customer_id, t.transaction_value)') + ->createSqlQuery(); + }, Fluent\Exceptions\QueryBuilderException::class, NULL, Fluent\Exceptions\QueryBuilderException::NO_ON_CONDITION); + } + + + public function testMergeNoWhen(): void + { + Tester\Assert::exception(function (): void { + $this->query() + ->merge('customer_account', 'ca') + ->using('recent_transactions', 't', 't.customer_id = ca.customer_id') + ->createSqlQuery(); + }, Fluent\Exceptions\QueryBuilderException::class, NULL, Fluent\Exceptions\QueryBuilderException::NO_WHEN); + } + + + public function testTruncate(): void + { + $query = $this->query()->truncate('table')->createSqlQuery()->createQuery(); + Tester\Assert::same('TRUNCATE table', $query->getSql()); + Tester\Assert::same([], $query->getParams()); + } + + public function testWith(): void { $query = $this->query() @@ -1320,7 +1463,7 @@ public function testHas(): void Tester\Assert::false($query->has($query::PARAM_DISTINCT)); Tester\Assert::false($query->has($query::PARAM_TABLES)); Tester\Assert::false($query->has($query::PARAM_TABLE_TYPES)); - Tester\Assert::false($query->has($query::PARAM_JOIN_CONDITIONS)); + Tester\Assert::false($query->has($query::PARAM_ON_CONDITIONS)); Tester\Assert::false($query->has($query::PARAM_WHERE)); Tester\Assert::false($query->has($query::PARAM_GROUPBY)); Tester\Assert::false($query->has($query::PARAM_HAVING)); @@ -1355,7 +1498,7 @@ public function testHas(): void Tester\Assert::true($query->has($query::PARAM_DISTINCT)); Tester\Assert::true($query->has($query::PARAM_TABLES)); Tester\Assert::true($query->has($query::PARAM_TABLE_TYPES)); - Tester\Assert::true($query->has($query::PARAM_JOIN_CONDITIONS)); + Tester\Assert::true($query->has($query::PARAM_ON_CONDITIONS)); Tester\Assert::true($query->has($query::PARAM_WHERE)); Tester\Assert::true($query->has($query::PARAM_GROUPBY)); Tester\Assert::true($query->has($query::PARAM_HAVING));