diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9864e02 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +/src/Tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml export-ignore +/phpcs.xml export-ignore +/captainhook.json export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45f673b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.idea +/composer.lock +/vendor +/.phpunit* +/tests/_output +/tests/_reports \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..752aa0b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Andreas Leathley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c26e464 --- /dev/null +++ b/README.md @@ -0,0 +1,431 @@ +Squirrel Queries Component +========================== + +Provides a slimmed down concise interface (DBInterface) for database queries and transactions. The limited interface is aimed to avoid confusion/misuse and encourage fail-safe usage. + +Doctrine is used as the underlying connection (and abstraction), what we add are an upsert (MERGE) functionality, structured queries which are easier to write and read (and make errors less likely), and the possibility to layer database concerns (like actual implementation, connections retries, performance measurements, logging, etc.). + +By default this library provides two layers, one dealing with Doctrine DBAL (passing the queries, processing and returning the results) and one dealing with errors (DBErrorHandler). DBErrorHandler catches deadlocks and connection problems and tries to repeat the query or transaction, and it unifies the exceptions coming from DBAL so the originating call to DBInterface is provided and the error can easily be found. + +Installation +------------ + + composer require squirrelphp/queries + +Usage +----- + +Use Squirrel\Queries\DBInterface as a type hint in your services. The interface options are based upon Doctrine and PDO with slight tweaks. If you know Doctrine or PDO you should be able to use this library easily. + +In addition, this library supports structured SELECT and UPDATE queries which break down the queries into its parts and take care of your field names and parameters automatically. + +For a solution which integrates easily with the Symfony framework, check out [squirrel/queries-bundle](https://github.com/squirrelphp/queries-bundle), and for entity and repository support check out [squirrel/repositories](https://github.com/squirrelphp/repositories) and [squirrel/repositories-bundle](https://github.com/squirrelphp/repositories-bundle). + +If you want to assemble a DBInterface object yourself, something like the following code can be a start: + + use Doctrine\DBAL\DriverManager; + use Squirrel\Queries\DBInterface; + use Squirrel\Queries\Doctrine\DBErrorHandler; + use Squirrel\Queries\Doctrine\DBMySQLImplementation; + + // Create a doctrine connection + $dbalConnection = DriverManager::getConnection([ + 'url' => 'mysql://user:secret@localhost/mydb' + ]); + + // Create a MySQL implementation layer + $implementationLayer = new DBMySQLImplementation($dbalConnection); + + // Create an error handler layer + $errorLayer = new DBErrorHandler(); + + // Set implementation layer beneath the error layer + $errorLayer->setLowerLayer($implementationLayer); + + // $errorLayer is now useable and can be injected + // anywhere you need it. Typehint it with + // \Squirrel\Queries\DBInterface + + $fetchEntry = function(DBInterface $db) { + return $db->fetchOne('SELECT * FROM table'); + }; + + $fetchEntry($errorLayer); + + // If you want to add more layers, you can create a + // class which implements DBRawInterface and includes + // the DBPassToLowerLayer trait and then just overwrite + // the functions you want to change, and then connect + // it to the other layers through setLowerLayer + + // It is also a good idea to catch \Squirrel\Queries\DBException + // in your application in case of a DB error so it + // can be handled gracefully + +### SELECT queries + +You can write your own SELECT queries with given parameters using the `select` function, then getting results with the `fetch` function and clearing the results with the `clear` function: + +```php +$selectStatement = $db->select('SELECT fieldname FROM tablename WHERE restriction = ? AND restriction2 = ?', [5, 8]); +$firstRow = $db->fetch($selectStatement); +$db->clear($selectStatement); +``` + +All ? are replaced by the array values in the second argument (those are the query parameters), if you have none you can omit the second argument: + +```php +$selectStatement = $db->select('SELECT fieldname FROM tablename WHERE restriction = 5 AND restriction2 = 8'); +``` + +It is recommended to use query parameters for any query data, even if it is fixed, because it is secure no matter where the data came from (like user input) and the charset or type does not matter (string, integer, boolean). + +`fetchOne` and `fetchAll` can be used instead of the `select` function to directly retrieve exactly one row (`fetchOne`) or all rows (`fetchAll`) for a SELECT query, for example: + +```php +$firstRow = $db->fetchOne('SELECT fieldname FROM tablename WHERE restriction = ? AND restriction2 = ?', [5, 8]); +``` +```php +$allRows = $db->fetchAll('SELECT fieldname FROM tablename WHERE restriction = ? AND restriction2 = ?', [5, 8]); +``` + +### Structured SELECT queries + +Instead of writing raw SQL you can use a structured query: + +```php +$selectStatement = $db->select([ + 'field' => 'fieldname', + 'table' => 'tablename', + 'where' => [ + 'restriction' => 5, + 'restriction2' => 8, + ], +]); +$firstRow = $db->fetch($selectStatement); +$db->clear($selectStatement); +``` + +In addition to being easier to write or process it also escapes field and table names, so the following string query is identical to the structured query above: + +```php +$selectStatement = $db->select('SELECT ´fieldname´ FROM ´tablename´ WHERE ´restriction´=? AND ´restriction2´=?', [5, 8]); +``` + +How field names and tables are quoted depends on Doctrine and its abstractions, so the escape character can differ according to the database engine. The above shows how MySQL would be escaped. + +Structured queries can replace almost all string select queries, even with multiple tables - this is a more complex example showing its options: + +```php +$selectStatement = $db->select([ + 'fields' => [ + 'fufumama', + 'b.lalala', + 'result' => 'a.setting_value', + 'result2' => ':a.setting_value:+:b.blabla_value:', + ], + 'tables' => [ + 'blobs.aa_sexy a', + ':blobs.aa_blubli: :b: LEFT JOIN :blobs.aa_blubla: :c: ON (:c.field: = :b.field5: AND :b.sexy: = ?)' => 5, + ], + 'where' => [ + ':a.field: = :b.field:', + 'setting_id' => 'orders_xml_override', + 'boring_field_name' => [5,3,8,13], + ':setting_value: = ? OR :setting_value2: = ?' => ['one','two'], + ], + 'group' => [ + 'a.field', + ], + 'order' => [ + 'a.field' => 'DESC', + ], + 'limit' => 10, + 'offset' => 5, + 'lock' => true, +]); +$firstRow = $db->fetch($selectStatement); +$db->clear($selectStatement); +``` + +This would be aquivalent to this string SELECT query (when using MySQL): + +```php +$selectStatement = $db->select('SELECT `fufumama`,`b`.`lalala`,`a`.`setting_value` AS "result",(`a`.`setting_value`+`b`.`blabla_value`) AS "result2" FROM `blobs`.`aa_sexy` `a`,`blobs`.`aa_blubli` `b` LEFT JOIN `blobs`.`aa_blubla` `c` ON (`c`.`field` = `b`.`field5` AND `b`.`sexy` = ?) WHERE (`a`.`field` = `b`.`field`) AND `setting_id`=? AND `boring_field_name` IN (?,?,?,?) AND (`setting_value` = ? OR `setting_value2` = ?) GROUP BY `a`.`field` ORDER BY `a`.`field` DESC LIMIT 10 OFFSET 5 FOR UPDATE', [5,'orders_xml_override',5,3,8,13,'one','two']); +``` + +Important parts of how the conversion works: + +- If an expression contains something like :fieldname: it is assumed that it is a field or table name which will then be escaped. For simple WHERE restrictions or fields definitions field names are escaped automatically. +- You can use "field" if there is just one field, or "fields" for multiple fields. The same with "table" and "tables". +- If you set "lock" to true "FOR UPDATE" is added to the query, so the results are locked within the current transaction. +- The arguments are checked as much as possible and if an option/expression is not valid, a DBInvalidOptionException is thrown. This does not include SQL errors, as the SQL components knows nothing of the allowed field names, table names or what constitutes a valid SQL expression. + +### Change queries + +Custom INSERT, UPDATE and DELETE queries (or other custom queries) can be executed with the `change` function, implying that this query changes something in contrast to a SELECT query: + +```php +$rowsAffected = $dbInterface->change('UPDATE users SET first_name = ?, last_name = ?, login_number = login_number + 1 WHERE user_id = ?', [ + 'Liam', // first_name + 'Henry', // last_name + 5, // user_id +]); +``` + +```php +$rowsAffected = $dbInterface->change('DELETE FROM users WHERE user_id = ? AND first_name = ?', [ + 5, // user_id + 'Liam', // first_name +]); +``` + +```php +$rowsAffected = $dbInterface->change('INSERT INTO users (user_id, first_name) SELECT user_id, first_name FROM users_backup'); +``` + +### Structured UPDATE queries + +TODO + +### INSERT + +TODO + +#### Example + +```php +// Does a prepared statement internally separating query and content, +// also quotes the table name and all the identifier names +$dbInterface->insert('yourdatabase.yourtable', [ + 'tableId' => 5, + 'column1' => 'Henry', + 'other_column' => 'Liam', +]); + +// Get the last insert ID if you have an autoincrement primary index: +$newInsertedId = $dbInterface->lastInsertId(); +``` + +### UPSERT / MERGE + +TODO + +#### Examples + +An example without using `$rowUpdates`, which means all `$row` entries are used for the update except for `$indexColumns`: + +```php +// Does a prepared statement internally separating query and content, +// also quotes the table name and all the field names +$dbInterface->upsert('yourdatabase.yourtable', [ + 'tableId' => 5, + 'column1' => 'Henry', + 'other_column' => 'Liam', +], [ + 'tableId', +]); +``` + +The first two arguments are identical to the normal insert function, the third defines the index columns which is your unique or primary key in the database. For MySQL this is converted into this prepared statement: + +```sql +INSERT INTO `yourdatabase`.`yourtable` (`tableId`,`column1`,`other_column`) VALUES (?,?,?) ON DUPLICATE KEY UPDATE `column1`=?,`other_column`=? +``` + +If you want to customize the UPDATE part you use `$rowUpdates`: + +```php +// Does a prepared statement internally separating query and content, +// also quotes the table name and all the field names +$dbInterface->upsert('yourdatabase.yourtable', [ + 'tableId' => 5, + 'column1' => 'Henry', + 'other_column' => 'Liam', + 'access_number' => 1, +], [ + 'tableId', +], [ + 'column1' => 'Henry', + 'access_number = access_number + 1', +]); +``` + +This is converted into this prepared statement for MySQL: + +```sql +INSERT INTO `yourdatabase`.`yourtable` (`tableId`,`column1`,`other_column`,`access_number`) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE `column1`=?,access_number = access_number + 1 +``` + +As you can see, we decided to not make the UPDATE identical to the INSERT - we only change column1 and we also inserted a custom SQL part with `access_number = access_number + 1`. Whenever an entry in `$rowUpdates` has no named key the value is used as-is in the SQL query. + +### UPDATE / DELETE / CUSTOM INSERT + +TODO + +### TRANSACTION + +Just pass a callable/function to the `transaction` method and DBInterface will take care of the commit/rollback parts automatically. + +#### Examples + +```php +$dbInterface->transaction(function(){ + // Do queries in here as much as you want, it will all be one transaction + // and committed as soon as this function ends +}); +``` + +An actual example might be: + +```php +$dbInterface->transaction(function() use ($dbInterface) { + $dbInterface->insert('myTable', [ + 'tableName' => 'Henry', + ]); + + $tableId = $dbInterface->lastInsertId(); + + $dbInterface->update('UPDATE otherTable SET tableId = ? WHERE tableName = ?', [$tableId, 'Henry']); +}); +``` + +If you call transaction within a transaction function, that function will just become part of the "outer transaction" and will fail or succeed with it: + +```php +$dbInterface->transaction(function() use ($dbInterface) { + $dbInterface->insert('myTable', [ + 'tableId' => 5, + 'tableName' => 'Henry', + ]); + + $tableId = $dbInterface->lastInsertId(); + + // This still does exactly the same as in the previous example, because the + // function will be executed without a "new" transaction being started, + // the existing one just continues + $dbInterface->transaction(function() use ($dbInterface, $tableId)) { + // If this fails, then the error handler will attempt to repeat the outermost + // transaction function, which is what you would want / expect, so it starts + // with the Henry insert again + $dbInterface->update('UPDATE otherTable SET tableId = ? WHERE tableName = ?', [$tableId, 'Henry']); + }); +}); +``` + +If there is a deadlock or connection problem, the error handler will roll back the transaction and attempt to retry it 10 times, with increasing wait times inbetween. Only if there are 10 failures within about 30 seconds will the exception be escalated with a DBException. + +If you want to pass arguments to $func, this would be an example: + +```php +$dbInterface->transaction(function($dbInterface, $table, $tableName) { + $dbInterface->insert($table, [ + 'tableName' => $tableName, + ]); + + $tableId = $dbInterface->lastInsertId(); + + $dbInterface->update('UPDATE otherTable SET tableId = ? WHERE tableName = ?', [$tableId, $tableName]); +}, $dbInterface, 'myTable', 'Henry'); +``` + +### QUOTE IDENTIFIERS + +```php +/** + * Quotes an identifier, like a table name or column name, so there is no risk + * of overlap with a reserved keyword + * + * @param string $identifier + * @return string + */ +public function quoteIdentifier(string $identifier) : string; +``` + +If you want to be safe it is recommended to quote all identifiers for the `select` and `update` function calls. For `insert` and `upsert` the quoting is done for you. + +If you quote all identifiers, then changing database systems (where different reserved keywords might exist) or upgrading a database (where new keywords might be reserved) is easier. + +#### Examples + +```php +$rowsAffected = $dbInterface->update('INSERT INTO ' . $dbInterface->quoteIdentifier('users') . ' (' . $dbInterface->quoteIdentifier('user_id') . ', ' . $dbInterface->quoteIdentifier('first_name') . ') SELECT ' . $dbInterface->quoteIdentifier('user_id') . ', ' . $dbInterface->quoteIdentifier('first_name') . ' FROM ' . $dbInterface->quoteIdentifier('users_backup')); +``` + +Guideline to use this library +----------------------------- + +To use this library to its fullest it is recommended to follow these guidelines: + +### Always separate the query from the data + +Instead of doing a query like this: + +```php +$rowsAffected = $dbInterface->update('UPDATE sessions SET time_zone = \'Europe/Zurich\' WHERE session_id = \'zzjEe2Jpksrjxsd05m1tOwnc7LJNV4sV\''); +``` + +Do it like this: + +```php +$rowsAffected = $dbInterface->update('UPDATE sessions SET time_zone = ? WHERE session_id = ?', [ + 'Europe/Zurich', + 'zzjEe2Jpksrjxsd05m1tOwnc7LJNV4sV', +]); +``` + +There are many advantages to separating the query from its data: + +1. You can safely use variables coming from a form/user, because SQL injections are impossible +2. Using ? placeholders is much easier than quoting/escaping data, and it does not matter if the data is a string or an int or something else +3. Queries become shorter and more readable +4. Using a different database system becomes easier, as you might use `"` to wrap strings in MySQL, while you would use `'` in PostgreSQL (`"` is used for identifiers). If you use ? placeholders you do not need to use any type of quotes for the data, so your queries become more universal. + +### Quote identifiers (table names and column names) + +It makes sense to quote all your table names and column names in order to avoid having any overlap with reserved keywords and making your queries more resilient. So instead of + +```php +$rowsAffected = $dbInterface->update('UPDATE sessions SET time_zone = ? WHERE session_id = ?', [ + 'Europe/Zurich', + 'zzjEe2Jpksrjxsd05m1tOwnc7LJNV4sV', +]); +``` + +You would change it to + +```php +$rowsAffected = $dbInterface->update('UPDATE ' . $dbInterface->quoteIdentifier('sessions') . ' SET ' . $dbInterface->quoteIdentifier('time_zone') . ' = ? WHERE ' . $dbInterface->quoteIdentifier('session_id') . ' = ?', [ + 'Europe/Zurich', + 'zzjEe2Jpksrjxsd05m1tOwnc7LJNV4sV', +]); +``` + +While this might seem overly verbose you are making your queries more future proof - if you upgrade your database system new reserved keywords could be added which conflict with a query, or changing the database system could lead to a different set of reserved keywords. + +### Use simple queries + +Avoid complicated queries if at all possible. Queries become increasingly complicated if: + +- more than two tables are involved +- GROUP BY is used +- subqueries are used +- database specific features are used (stored procedures, triggers, views, etc.) + +It is often tempting to solve many problems with one query, but the downsides are plentiful: + +- Performance decreases the more complex a query becomes +- Multiple short queries can be cached and load-balanced better than one big query +- Porting a complex query to a different database system might necessitate many changes +- Understanding and changing complex queries is a lot harder, so errors are more likely + +Sometimes a complex query can make more sense, but it should be the rare exception for less than 1% of cases. + +### Use squirrel/queries-bundle and squirrel/repositories + +`squirrelphp/repositories` is a library built on top of `squirrelphp/queries` and offers easy manipulation of database tables and follows all the above guidelines. `squirrelphp/repositories-bundle` is the Symfony bundle integrating repositories into a Symfony project. + +Why don't you support X? Why is feature Y not included? +------------------------------------------------------- + +This package was built for my needs originally. If you have sensible additional needs which should be considered, please open an issue or make a pull request. Keep in mind that the focus of the DBInterface itself is narrow by design. \ No newline at end of file diff --git a/captainhook.json b/captainhook.json new file mode 100644 index 0000000..f2532d5 --- /dev/null +++ b/captainhook.json @@ -0,0 +1,60 @@ +{ + "commit-msg": { + "enabled": true, + "actions": [ + { + "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\Beams", + "options": { + "subjectLength": 50, + "bodyLineLength": 72 + }, + "conditions": [] + } + ] + }, + "pre-push": { + "enabled": false, + "actions": [] + }, + "pre-commit": { + "enabled": true, + "actions": [ + { + "action": "\\CaptainHook\\App\\Hook\\PHP\\Action\\Linting", + "options": [], + "conditions": [] + }, + { + "action": "vendor/bin/phpunit", + "options": [], + "conditions": [] + }, + { + "action": "vendor/bin/phpstan analyse src --level=7", + "options": [], + "conditions": [] + }, + { + "action": "vendor/bin/phpcs --standard=psr2 --extensions=php src tests", + "options": [], + "conditions": [] + } + ] + }, + "prepare-commit-msg": { + "enabled": false, + "actions": [] + }, + "post-commit": { + "enabled": false, + "actions": [] + }, + "post-merge": { + "enabled": false, + "actions": [] + }, + "post-checkout": { + "enabled": false, + "actions": [] + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1524bff --- /dev/null +++ b/composer.json @@ -0,0 +1,57 @@ +{ + "name": "squirrelphp/queries", + "type": "library", + "description": "Slimmed down concise interface for database queries and transactions (DBInterface) which can be layered / decorated.", + "keywords": [ + "php", + "mysql", + "pgsql", + "sqlite", + "database", + "abstraction" + ], + "homepage": "https://github.com/squirrelphp/queries", + "license": "MIT", + "authors": [ + { + "name": "Andreas Leathley", + "email": "andreas.leathley@panaxis.ch" + } + ], + "require": { + "php": "^7.2", + "doctrine/dbal": "^2.0" + }, + "require-dev": { + "ext-pdo": "*", + "mockery/mockery": "^1.0", + "phpstan/phpstan": "^0.11.5", + "phpunit/phpunit": "^8.0", + "squizlabs/php_codesniffer": "^3.0", + "captainhook/plugin-composer": "^4.0" + }, + "suggest": { + "squirrelphp/queries-bundle": "Symfony integration of squirrelphp/queries - automatic assembling of decorated connections", + "squirrelphp/repositories": "Makes defining entities possible", + "squirrelphp/repositories-bundle": "Automatic integration of squirrelphp/repositories in Symfony" + }, + "config": { + "sort-packages": true + }, + "autoload": { + "psr-4": { + "Squirrel\\Queries\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Squirrel\\Queries\\Tests\\": "tests/" + } + }, + "scripts": { + "phpstan": "vendor/bin/phpstan analyse src --level=7", + "phpunit": "vendor/bin/phpunit --colors=always", + "phpcs": "vendor/bin/phpcs --standard=psr2 --extensions=php src tests", + "codecoverage": "vendor/bin/phpunit --coverage-html tests/_reports" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..9f44502 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,21 @@ + + + + + + tests + + + + + + src + + + diff --git a/src/DBDebug.php b/src/DBDebug.php new file mode 100644 index 0000000..50aaa36 --- /dev/null +++ b/src/DBDebug.php @@ -0,0 +1,124 @@ + $value) { + $result[] = \is_int($key) ? self::sanitizeData($value) : "'" . $key . "' => " . self::sanitizeData($value); + } + + return \implode(', ', $result); + } + + /** + * Convert debug data into a sanitized string + * + * @param mixed $data + * @return mixed + */ + public static function sanitizeData($data) + { + // Convert object to class name + if (\is_object($data)) { + return 'object(' . (new \ReflectionClass($data))->getShortName() . ')'; + } + + // Convert resource to its type name + if (\is_resource($data)) { + return 'resource(' . \get_resource_type($data) . ')'; + } + + // Convert boolean to integer + if (\is_bool($data)) { + return \strtolower(\var_export($data, true)); + } + + // All other non-array values are fine + if (!\is_array($data)) { + return \str_replace("\n", '', \var_export($data, true)); + } + + // Go through all values in the array and process them recursively + foreach ($data as $key => $value) { + $formattedValue = self::sanitizeData($value); + $result[] = \is_int($key) ? $formattedValue : "'" . $key . "' => " . $formattedValue; + } + + return '[' . \implode(', ', $result ?? []) . ']'; + } +} diff --git a/src/DBException.php b/src/DBException.php new file mode 100644 index 0000000..706661a --- /dev/null +++ b/src/DBException.php @@ -0,0 +1,77 @@ +sqlCmd = $sqlCmd; + $this->sqlFile = $sqlFile; + $this->sqlLine = $sqlLine; + } + + /** + * @return string + */ + public function getSqlCmd(): string + { + return $this->sqlCmd; + } + + /** + * @return string + */ + public function getSqlFile(): string + { + return $this->sqlFile; + } + + /** + * @return string + */ + public function getSqlLine(): string + { + return $this->sqlLine; + } +} diff --git a/src/DBInterface.php b/src/DBInterface.php new file mode 100644 index 0000000..66a9d83 --- /dev/null +++ b/src/DBInterface.php @@ -0,0 +1,165 @@ +lowerLayer->transaction($func, ...$arguments); + } + + /** + * @inheritDoc + */ + public function inTransaction(): bool + { + return $this->lowerLayer->inTransaction(); + } + + /** + * @inheritDoc + */ + public function select(string $query, array $vars = []): DBSelectQueryInterface + { + return $this->lowerLayer->select($query, $vars); + } + + /** + * @inheritDoc + */ + public function fetch(DBSelectQueryInterface $selectQuery): ?array + { + return $this->lowerLayer->fetch($selectQuery); + } + + /** + * @inheritDoc + */ + public function clear(DBSelectQueryInterface $selectQuery): void + { + $this->lowerLayer->clear($selectQuery); + } + + /** + * @inheritDoc + */ + public function fetchOne(string $query, array $vars = []): ?array + { + return $this->lowerLayer->fetchOne($query, $vars); + } + + /** + * @inheritDoc + */ + public function fetchAll(string $query, array $vars = []): array + { + return $this->lowerLayer->fetchAll($query, $vars); + } + + /** + * @inheritDoc + */ + public function insert(string $tableName, array $row = []): int + { + return $this->lowerLayer->insert($tableName, $row); + } + + /** + * @inheritDoc + */ + public function upsert(string $tableName, array $row = [], array $indexColumns = [], array $rowUpdates = []): int + { + return $this->lowerLayer->upsert($tableName, $row, $indexColumns, $rowUpdates); + } + + /** + * @inheritDoc + */ + public function update(array $query): int + { + return $this->lowerLayer->update($query); + } + + /** + * @inheritDoc + */ + public function delete(string $tableName, array $where = []): int + { + return $this->lowerLayer->delete($tableName, $where); + } + + /** + * @inheritDoc + */ + public function change(string $query, array $vars = []): int + { + return $this->lowerLayer->change($query, $vars); + } + + /** + * @inheritDoc + */ + public function lastInsertId($name = null): string + { + return $this->lowerLayer->lastInsertId($name); + } + + /** + * @inheritDoc + */ + public function quoteIdentifier(string $identifier): string + { + return $this->lowerLayer->quoteIdentifier($identifier); + } + + /** + * @inheritDoc + */ + public function setTransaction(bool $inTransaction): void + { + $this->lowerLayer->setTransaction($inTransaction); + } + + /** + * @inheritDoc + */ + public function getConnection(): object + { + return $this->lowerLayer->getConnection(); + } + + /** + * @inheritDoc + */ + public function setLowerLayer($lowerLayer): void + { + $this->lowerLayer = $lowerLayer; + } +} diff --git a/src/DBRawInterface.php b/src/DBRawInterface.php new file mode 100644 index 0000000..5edf64f --- /dev/null +++ b/src/DBRawInterface.php @@ -0,0 +1,35 @@ +connection = $connection; + $this->structuredQueryConverter = new DBConvertStructuredQueryToSQL([$this, 'quoteIdentifier']); + } + + /** + * @inheritDoc + */ + public function transaction(callable $func, ...$arguments) + { + // If we are already in a transaction we just run the function + if ($this->inTransaction === true) { + return $func(...$arguments); + } + + // Record in class as "we are in a transaction" + $this->inTransaction = true; + + // Start transaction + $this->connection->beginTransaction(); + + // Run the function and commit transaction + $result = $func(...$arguments); + $this->connection->commit(); + + // Go back to "we are not in a transaction anymore" + $this->inTransaction = false; + + // Return result from the function + return $result; + } + + /** + * @inheritDoc + */ + public function inTransaction(): bool + { + return $this->inTransaction; + } + + /** + * @inheritDoc + */ + public function select($query, array $vars = []): DBSelectQueryInterface + { + // Convert structured query into a string query with variables + if (is_array($query)) { + $flattenFields = \boolval($query['flattenFields'] ?? false); + [$query, $vars] = $this->convertStructuredSelectToQuery($query); + } + + // Prepare and execute query + $statement = $this->connection->prepare($query); + $statement->execute($vars); + + // Return select query object with PDO statement + return new DBSelectQuery($statement, (isset($flattenFields) && $flattenFields === true ? true : false)); + } + + /** + * @inheritDoc + */ + public function fetch(DBSelectQueryInterface $selectQuery): ?array + { + // Make sure we have a valid DBSelectQuery object + if (!($selectQuery instanceof DBSelectQuery)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid select query class provided' + ); + } + + // Get the result - can be an array of the entry, or false if it is empty + $result = $selectQuery->getStatement()->fetch(FetchMode::ASSOCIATIVE); + + // Flatten results means we get rid of the field names + if ($selectQuery->hasFlattenFields() === true && is_array($result)) { + return \array_values($result); + } + + // Return one result as an array + return ($result === false ? null : $result); + } + + /** + * @inheritDoc + */ + public function clear(DBSelectQueryInterface $selectQuery): void + { + // Make sure we have a valid DBSelectQuery object + if (!($selectQuery instanceof DBSelectQuery)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid select query class provided' + ); + } + + // Close the result set + $selectQuery->getStatement()->closeCursor(); + } + + /** + * @inheritDoc + */ + public function fetchOne($query, array $vars = []): ?array + { + // Convert structured query into a string query with variables + if (is_array($query)) { + $query['limit'] = 1; + } + + // Use our internal functions to not repeat ourselves + $selectQuery = $this->select($query, $vars); + $result = $this->fetch($selectQuery); + $this->clear($selectQuery); + + // Return query result + return $result; + } + + /** + * @inheritDoc + */ + public function fetchAll($query, array $vars = []): array + { + // Convert structured query into a string query with variables + if (is_array($query)) { + $flattenFields = \boolval($query['flattenFields'] ?? false); + [$query, $vars] = $this->convertStructuredSelectToQuery($query); + } + + // Prepare and execute query + $statement = $this->connection->prepare($query); + $statement->execute($vars); + + // Get result and close result set + $result = $statement->fetchAll(FetchMode::ASSOCIATIVE); + $statement->closeCursor(); + + // We flatten the fields if requested + if (isset($flattenFields) && $flattenFields === true && count($result) > 0) { + // New flattened array + $list = []; + + // Go through results and reduce the array to just a list of column values + foreach ($result as $entryKey => $entryArray) { + foreach ($entryArray as $fieldName => $fieldValue) { + $list[] = $fieldValue; + } + } + + // Returned flattened results + return $list; + } + + // Return query result + return $result; + } + + /** + * @inheritDoc + */ + public function insert(string $tableName, array $row = []): int + { + // No table name specified + if (strlen($tableName) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'No table name specified for insert' + ); + } + + // Make table name safe by quoting it + $tableName = $this->quoteIdentifier($tableName); + + // Divvy up the field names, values and placeholders + $columnNames = array_keys($row); + $columnValues = array_values($row); + $placeholders = array_fill(0, count($row), '?'); + + // Generate the insert query + $query = 'INSERT INTO ' . $tableName . + ' (' . (count($row) > 0 ? implode(',', array_map([$this, 'quoteIdentifier'], $columnNames)) : '') . ') ' . + 'VALUES (' . (count($row) > 0 ? implode(',', $placeholders) : '') . ')'; + + // Return number of affected rows + return $this->change($query, $columnValues); + } + + /** + * @inheritDoc + */ + public function update(array $query): int + { + // Convert the structure into a query string and query variables + [$queryAsString, $vars] = $this->convertStructuredUpdateToQuery($query); + + // Call the change function to avoid duplication + return $this->change($queryAsString, $vars); + } + + /** + * @inheritDoc + */ + public function delete(string $tableName, array $where = []): int + { + // No table name specified + if (strlen($tableName) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'No table name specified for delete' + ); + } + + // Build the WHERE part of the query + [$whereSQL, $queryValues] = $this->structuredQueryConverter->buildWhere($where, []); + + // Compile the DELETE query + $query = 'DELETE FROM ' . $this->quoteIdentifier($tableName) . ' WHERE ' . $whereSQL; + + // Use our existing update function so there is no duplication + return $this->change($query, $queryValues); + } + + /** + * @inheritDoc + */ + public function change(string $query, array $vars = []): int + { + // Prepare and execute query + $statement = $this->connection->prepare($query); + $statement->execute($vars); + + // Get affected rows + $result = $statement->rowCount(); + + // Close query + $statement->closeCursor(); + + // Return affected rows + return $result; + } + + /** + * @inheritDoc + */ + public function lastInsertId($name = null): string + { + return $this->connection->lastInsertId($name); + } + + /** + * @inheritDoc + */ + public function quoteIdentifier(string $identifier): string + { + return $this->connection->quoteIdentifier($identifier); + } + + private function convertStructuredSelectToQuery(array $select): array + { + // Make sure all options are correctly defined + $select = $this->structuredQueryConverter->verifyAndProcessOptions([ + 'fields' => [], + 'tables' => [], + 'where' => [], + 'group' => [], + 'order' => [], + 'limit' => 0, + 'offset' => 0, + 'lock' => false, + 'flattenFields' => false, + ], $select); + + // Generate field select SQL (between SELECT and FROM) + $fieldSelectionSQL = $this->structuredQueryConverter->buildFieldSelection($select['fields'] ?? []); + + // Build table joining SQL (between FROM and WHERE) + [$tableJoinsSQL, $queryValues] = $this->structuredQueryConverter->buildTableJoins($select['tables']); + + // Build the WHERE part of the query + [$whereSQL, $queryValues] = $this->structuredQueryConverter->buildWhere($select['where'], $queryValues); + + // Build the GROUP BY part of the query if specified + if (isset($select['group'])) { + $groupSQL = $this->structuredQueryConverter->buildGroupBy($select['group']); + } + + // Build the ORDER BY part of the query if specified + if (isset($select['order'])) { + $orderSQL = $this->structuredQueryConverter->buildOrderBy($select['order']); + } + + // Generate SELECT query + $sql = 'SELECT ' . $fieldSelectionSQL . ' FROM ' . $tableJoinsSQL . + (strlen($whereSQL) > 1 ? ' WHERE ' . $whereSQL : '') . + (isset($groupSQL) && strlen($groupSQL) > 0 ? ' GROUP BY ' . $groupSQL : '') . + (isset($orderSQL) && strlen($orderSQL) > 0 ? ' ORDER BY ' . $orderSQL : ''); + + // Add limit for results + if ((isset($select['limit']) && $select['limit'] > 0) || (isset($select['offset']) && $select['offset'] > 0)) { + $sql = $this->connection->getDatabasePlatform()->modifyLimitQuery( + $sql, + $select['limit'] ?? null, + $select['offset'] ?? null + ); + } + + // Lock the result set + if ($select['lock'] === true) { + $sql .= ' FOR UPDATE'; + } + + return [$sql, $queryValues]; + } + + private function convertStructuredUpdateToQuery(array $update): array + { + // Make sure all options are correctly defined + $update = $this->structuredQueryConverter->verifyAndProcessOptions([ + 'changes' => [], + 'tables' => [], + 'where' => [], + 'order' => [], + 'limit' => 0, + ], $update); + + // Build table joining SQL (between UPDATE and SET) + [$tableJoinsSQL, $queryValues] = $this->structuredQueryConverter->buildTableJoins($update['tables']); + + // Generate changes SQL (SET part) + [$changeSQL, $queryValues] = $this->structuredQueryConverter->buildChanges($update['changes'], $queryValues); + + // Build the WHERE part of the query + [$whereSQL, $queryValues] = $this->structuredQueryConverter->buildWhere($update['where'], $queryValues); + + // Build the ORDER BY part of the query if specified + if (isset($update['order'])) { + $orderSQL = $this->structuredQueryConverter->buildOrderBy($update['order']); + } + + // Generate SELECT query + $sql = 'UPDATE ' . $tableJoinsSQL . ' SET ' . $changeSQL . + (strlen($whereSQL) > 1 ? ' WHERE ' . $whereSQL : '') . + (isset($orderSQL) && strlen($orderSQL) > 0 ? ' ORDER BY ' . $orderSQL : ''); + + // Add limit for results + if (isset($update['limit']) && $update['limit'] > 0) { + $sql = $this->connection->getDatabasePlatform()->modifyLimitQuery($sql, $update['limit']); + } + + return [$sql, $queryValues]; + } + + /** + * @inheritDoc + */ + public function setTransaction(bool $inTransaction): void + { + $this->inTransaction = $inTransaction; + } + + /** + * @inheritDoc + */ + public function getConnection(): object + { + return $this->connection; + } + + /** + * @inheritDoc + */ + public function setLowerLayer($lowerLayer): void + { + throw new \LogicException('Lower DBRawInterface layers cannot be set in ' . __METHOD__ . + ' because we are already at the lowest level of implementation'); + } +} diff --git a/src/Doctrine/DBConvertStructuredQueryToSQL.php b/src/Doctrine/DBConvertStructuredQueryToSQL.php new file mode 100644 index 0000000..0f20eed --- /dev/null +++ b/src/Doctrine/DBConvertStructuredQueryToSQL.php @@ -0,0 +1,598 @@ +quoteIdentifier = $quoteIdentifier; + } + + /** + * Process options and make sure all values are valid + * + * @param array $validOptions List of valid options and default values for them + * @param array $options List of provided options which need to be processed + * @return array + */ + public function verifyAndProcessOptions(array $validOptions, array $options) + { + // One table shortcut - convert to "tables" array + if (isset($options['table']) && !isset($options['tables']) && isset($validOptions['tables'])) { + $options['tables'] = [$options['table']]; + unset($options['table']); + } + + // One field shortcut - convert to "fields" array + if (isset($options['field']) && !isset($options['fields']) && isset($validOptions['fields'])) { + $options['fields'] = [$options['field']]; + unset($options['field']); + } + + // Copy over the default valid options as a starting point for our options + $sanitizedOptions = $validOptions; + + // Options were defined + foreach ($options as $optKey => $optVal) { + // Defined option is not in the list of valid options + if (!isset($validOptions[$optKey])) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Unknown option key ' . DBDebug::sanitizeData($optKey) + ); + } + + // Make sure the variable type for the defined option is valid + switch ($optKey) { + case 'lock': + case 'flattenFields': + // Conversion of value does not match the original value, so we have a very wrong type + if (!\is_bool($optVal) && \intval(\boolval($optVal)) !== \intval($optVal)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Option key ' . DBDebug::sanitizeData($optKey) + . ' had an invalid value which cannot be converted correctly' + ); + } + + $optVal = \boolval($optVal); + break; + case 'limit': + case 'offset': + // Conversion of value does not match the original value, so we have a very wrong type + if (\is_bool($optVal) || (!\is_int($optVal) && \strval(\intval($optVal)) !== \strval($optVal))) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Option key ' . DBDebug::sanitizeData($optKey) . + ' had an invalid value which cannot be converted correctly' + ); + } + + $optVal = \intval($optVal); + break; + default: + if (!\is_array($optVal)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Option key ' . DBDebug::sanitizeData($optKey) . ' had a non-array value' + ); + } + break; + } + + $sanitizedOptions[$optKey] = $optVal; + } + + // Make sure tables array was defined + if (!isset($sanitizedOptions['tables']) || \count($sanitizedOptions['tables']) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'No tables specified for query' + ); + } + + // Limit must be a positive integer if defined + if (isset($validOptions['limit']) && $sanitizedOptions['limit'] < 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Below zero "limit" definition' + ); + } + + // Offset must be a positive integer if defined + if (isset($validOptions['offset']) && $sanitizedOptions['offset'] < 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Below zero "offset" definition' + ); + } + + // Changes in update query need to be defined + if (isset($validOptions['changes']) && \count($sanitizedOptions['changes']) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'No "changes" definition' + ); + } + + // Return all processed options and object-to-table information + return $sanitizedOptions; + } + + /** + * Build fields selection part of the query (for SELECT) + * + * @param array $fields + * @return string + */ + public function buildFieldSelection(array $fields) + { + // No fields mean we select all fields! + if (\count($fields) === 0) { + return '*'; + } + + // Calculated select fields + $fieldSelectionList = []; + + // Go through all the select fields + foreach ($fields as $name => $field) { + // Field always has to be a string + if (!\is_string($field)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid "fields" definition, value for ' . + DBDebug::sanitizeData($name) . ' is not a string' + ); + } + + // No expressions allowed in name part! + if (!\is_int($name) && \strpos($name, ':') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid "fields" definition, name ' . + DBDebug::sanitizeData($name) . ' contains a colon' + ); + } + + // Whether this was an expression (according to special characters found) + $isExpression = false; + + if (\strpos($field, ':') !== false + || \strpos($field, ' ') !== false + || \strpos($field, '(') !== false + || \strpos($field, ')') !== false + || \strpos($field, '*') !== false + ) { // Special characters found, so this is an expression + $fieldProcessed = (\strpos($field, ':') !== false ? $this->escapeVariablesInSqlPart($field) : $field); + + // This is now a special expression + $isExpression = true; + } else { // No colons, we assume it is just a field definition to escape - no expression + $fieldProcessed = ($this->quoteIdentifier)($field); + } + + // If no (unique) name was given, we always just use the processed field as is + if (\is_int($name) || $name === $field) { + $fieldSelectionList[] = $fieldProcessed; + } else { // Explicit name different from field name was given + if ($isExpression) { + $fieldSelectionList[] = '(' . $fieldProcessed . ') AS "' . $name . '"'; + } else { + $fieldSelectionList[] = $fieldProcessed . ' AS "' . $name . '"'; + } + } + } + + return \implode(',', $fieldSelectionList); + } + + /** + * Build FROM or UPDATE part for the query (both are built the same) + * + * @param array $tables + * @return array + */ + public function buildTableJoins(array $tables) + { + // List of query values for PDO + $queryValues = []; + + // List of table selection, needs to be imploded with a comma for SQL query + $joinedTables = []; + + // Go through table selection + foreach ($tables as $expression => $values) { + // No values, only an expression + if (\is_int($expression)) { + $expression = $values; + $values = []; + } + + // Expression always has to be a string + if (!\is_string($expression)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid "tables" definition, expression is not a string: ' . + DBDebug::sanitizeData($expression) + ); + } + + // No variable expression with colons + if (\strpos($expression, ':') === false) { + // Count number of spaces in expression + $spacesNumber = \substr_count($expression, ' '); + + if ($spacesNumber === 0) { // No space found, we assume it is a pure table name + $expression = ($this->quoteIdentifier)($expression); + } elseif ($spacesNumber === 1) { // One space found, we assume table name + alias + $expression = \implode(' ', \array_map($this->quoteIdentifier, \explode(' ', $expression))); + } + + // Everything else is left as-is - maybe an expression or something we do not understand + } else { // An expression with : variables + $expression = $this->escapeVariablesInSqlPart($expression); + } + + // Add to list of joined tables + $joinedTables[] = $expression; + + // Add new parameters to query parameters + $queryValues = $this->addQueryVariablesNoNull($queryValues, $values); + } + + return [\implode(',', $joinedTables), $queryValues]; + } + + /** + * Build UPDATE SET clause and add query values + * + * @param array $changes + * @param array $queryValues + * @return array + */ + public function buildChanges(array $changes, array $queryValues) + { + // List of finished change expressions, to be imploded with , + $changesList = []; + + // Go through table selection + foreach ($changes as $expression => $values) { + if (\is_int($expression)) { + $expression = $values; + $values = []; + } + + // Expression always has to be a string + if (!\is_string($expression)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid "changes" definition, expression is not a string: ' . DBDebug::sanitizeData($expression) + ); + } + + // No assignment operator, meaning we have a fieldName => value entry + if (\strpos($expression, '=') === false) { + // If we have no value to assign there is a problem + if (!\is_scalar($values) && !\is_null($values)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid "changes" definition, no assignment operator and ' . + 'no/invalid value given, so nothing to change: ' . DBDebug::sanitizeData($expression) + ); + } + + // Colons are not allowed in a variable name + if (\strpos($expression, ':') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid "changes" definition, colon used in a field name ' . + 'to value assignment: ' . DBDebug::sanitizeData($expression) + ); + } + + // Simple assignment expression + $expression = ($this->quoteIdentifier)($expression) . '=?'; + } else { // Assignment operator exists in expression + // Process variables if any exist in the string + if (\strpos($expression, ':') !== false) { + $expression = $this->escapeVariablesInSqlPart($expression); + } + } + + // Add to list of finished WHERE expressions + $changesList[] = $expression; + + // Add new parameters to query parameters + $queryValues = $this->addQueryVariables($queryValues, $values); + } + + return [\implode(',', $changesList), $queryValues]; + } + + /** + * Build WHERE clause and add query values + * + * @param array $whereOptions + * @param array $queryValues + * @return array + */ + public function buildWhere(array $whereOptions, array $queryValues = []) + { + // If no WHERE restrictions are defined, we just do "WHERE 1" + if (\count($whereOptions) === 0) { + return ['1', $queryValues]; + } + + // List of finished WHERE expressions, to be imploded with ANDs + $whereProcessed = []; + + // Go through table selection + foreach ($whereOptions as $expression => $values) { + // Switch around expression and values if there are no values + if (\is_int($expression)) { + $expression = $values; + $values = []; + } + + // Expression always has to be a string + if (!\is_string($expression)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid "where" definition, expression is not a string: ' . + DBDebug::sanitizeData($expression) + ); + } + + // Check if this is a custom expression, not just a field name to value expression + if (\strpos($expression, ' ') !== false + || \strpos($expression, '=') !== false + || \strpos($expression, '<') !== false + || \strpos($expression, '>') !== false + || \strpos($expression, '(') !== false + || \strpos($expression, ')') !== false + ) { + // Colons found, which are used to escape variables + if (\strpos($expression, ':') !== false) { + $expression = $this->escapeVariablesInSqlPart($expression); + } + + // Add to list of finished WHERE expressions + $whereProcessed[] = '(' . $expression . ')'; + } else { // We assume just a field name to value(s) expression + // Values have to be defined for us to make a predefined equals query + if (\is_array($values) && \count($values) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid "where" definition, simple expression has no values: ' . + DBDebug::sanitizeData($expression) + ); + } + + // Special case for NULL - then we need the IS NULL expression + if (\is_null($values)) { + $expression = ($this->quoteIdentifier)($expression) . ' IS NULL'; + $values = []; + } elseif (\is_array($values) && \count($values) > 1) { // Array values => IN where query + $expression = ($this->quoteIdentifier)($expression) . + ' IN (' . \implode(',', \array_fill(0, \count($values), '?')) . ')'; + } else { // Scalar value, so we do a regular equal query + $expression = ($this->quoteIdentifier)($expression) . '=?'; + } + + // Add to list of finished WHERE expressions + $whereProcessed[] = $expression; + } + + // Add new parameters to query parameters + $queryValues = $this->addQueryVariablesNoNull($queryValues, $values); + } + + return [\implode(' AND ', $whereProcessed), $queryValues]; + } + + /** + * Build GROUP BY clause + * + * @param array $groupByOptions + * @return string + */ + public function buildGroupBy(array $groupByOptions) + { + // List of finished WHERE expressions, to be imploded with ANDs + $groupByProcessed = []; + + // Go through table selection + foreach ($groupByOptions as $expression => $values) { + // Switch around expression and values if there are no values + if (\is_int($expression)) { + $expression = $values; + $values = null; + } + + // Expression always has to be a string + if (!\is_string($expression)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid "group" definition, expression is not a string: ' . + DBDebug::sanitizeData($expression) + ); + } + + // Add to list of finished expressions + $groupByProcessed[] = ($this->quoteIdentifier)($expression); + } + + return \implode(',', $groupByProcessed); + } + + /** + * Build ORDER BY clause + * + * @param array $orderOptions + * @return string + */ + public function buildOrderBy(array $orderOptions) + { + // List of finished WHERE expressions, to be imploded with ANDs + $orderProcessed = []; + + // Go through table selection + foreach ($orderOptions as $expression => $order) { + // If there is no explicit order we set it to ASC + if (\is_int($expression)) { + $expression = $order; + $order = 'ASC'; + } + + // Expression always has to be a string + if (!\is_string($expression)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid "order" definition, expression is not a string: ' . + DBDebug::sanitizeData($expression) + ); + } + + // Make sure the order is ASC or DESC - nothing else is allowed + if (!\is_string($order) || ($order !== 'ASC' && $order !== 'DESC')) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid "order" definition, order is not ASC or DESC: ' . + DBDebug::sanitizeData($order) + ); + } + + // Wether variable was found or not + $variableFound = (\strpos($expression, ':') !== false); + + // Expression contains not just the field name + if ($variableFound === true + || \strpos($expression, ' ') !== false + || \strpos($expression, '(') !== false + || \strpos($expression, ')') !== false + ) { + if ($variableFound === true) { + $expression = $this->escapeVariablesInSqlPart($expression); + } + } else { // Expression is just a field name + $expression = ($this->quoteIdentifier)($expression); + } + + $orderProcessed[] = $expression . ' ' . $order; + } + + return \implode(',', $orderProcessed); + } + + /** + * Add query variables to existing values + * + * @param array $existingValues + * @param mixed $newValues + * @return array + */ + private function addQueryVariables(array $existingValues, $newValues) + { + // Convert to array of values if not already done + if (!\is_array($newValues)) { + $newValues = [$newValues]; + } + + // Add all the values to the query values + foreach ($newValues as $value) { + // Only scalar values and NULL are allowed + if (!\is_scalar($value) && !\is_null($value)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid query variable specified, it is non-scalar: ' . + DBDebug::sanitizeData($newValues) + ); + } + + // Convert bool to int + if (\is_bool($value)) { + $value = \intval($value); + } + + $existingValues[] = $value; + } + + return $existingValues; + } + + /** + * Add query variables to existing values - but NULL is not allowed as a value + * + * @param array $existingValues + * @param mixed $newValues + * @return array + */ + private function addQueryVariablesNoNull(array $existingValues, $newValues) + { + // Convert to array of values if not already done + if (!\is_array($newValues)) { + $newValues = [$newValues]; + } + + // If there are multiple values, check if any element in the array has a null value + foreach ($newValues as $value) { + if (\is_null($value)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + DBInterface::class, + 'Invalid query variable specified, NULL provided where NULL is not allowed' + ); + } + } + + // null not found - add query variables as usual + return $this->addQueryVariables($existingValues, $newValues); + } + + /** + * Escape variables (with starting and ending colons, like :variable:) + * + * @param string $expression + * @return string + */ + private function escapeVariablesInSqlPart(string $expression) + { + return \preg_replace_callback('/[:]([^:]+)[:]/si', function ($matches) { + return ($this->quoteIdentifier)($matches[1]); + }, $expression) ?? $expression; + } +} diff --git a/src/Doctrine/DBErrorHandler.php b/src/Doctrine/DBErrorHandler.php new file mode 100644 index 0000000..b1d472b --- /dev/null +++ b/src/Doctrine/DBErrorHandler.php @@ -0,0 +1,406 @@ +connectionRetries = \array_map('intval', $connectionRetries); + } + + /** + * Change deadlock retries configuration + * + * @param array $lockRetries + */ + public function setLockRetries(array $lockRetries) + { + $this->lockRetries = \array_map('intval', $lockRetries); + } + + /** + * @inheritDoc + */ + public function transaction(callable $func, ...$arguments) + { + // If we are already in a transaction we just run the function + if ($this->lowerLayer->inTransaction() === true) { + return $func(...$arguments); + } + + // Do a full transaction and try to repeat it if necessary + return $this->transactionExecute($func, $arguments, $this->connectionRetries, $this->lockRetries); + } + + /** + * Execute transaction - attempts to do it and repeats it if there was a problem + * + * @param callable $func + * @param array $arguments + * @param array $connectionRetries + * @param array $lockRetries + * @return mixed + * + * @throws DBException + */ + protected function transactionExecute( + callable $func, + array $arguments, + array $connectionRetries, + array $lockRetries + ) { + try { + return $this->lowerLayer->transaction($func, ...$arguments); + } catch (DeadlockException | LockWaitTimeoutException $e) { // Deadlock or lock timeout occured + // Attempt to roll back + try { + /** + * @var Connection $connection + */ + $connection = $this->lowerLayer->getConnection(); + $connection->rollBack(); + } catch (\Exception $eNotUsed) { + } + + // Set flag for "not in a transaction" + $this->setTransaction(false); + + // We have exhaused all deadlock retries and it is time to give up + if (count($lockRetries) === 0) { + throw DBDebug::createException(DBLockException::class, DBInterface::class, $e->getMessage()); + } + + // Wait for a certain amount of microseconds + \usleep(\array_shift($lockRetries)); + + // Repeat transaction + return $this->transactionExecute($func, $arguments, $connectionRetries, $lockRetries); + } catch (ConnectionException $e) { // Connection error occured + // Attempt to roll back, suppress any possible exceptions + try { + /** + * @var Connection $connection + */ + $connection = $this->lowerLayer->getConnection(); + $connection->rollBack(); + } catch (\Exception $eNotUsed) { + } + + // Set flag for "not in a transaction" + $this->setTransaction(false); + + // Attempt to reconnect according to $connectionRetries + $connectionRetries = $this->attemptReconnect($connectionRetries); + + // Reconnecting was unsuccessful + if ($connectionRetries === false) { + throw DBDebug::createException(DBConnectionException::class, DBInterface::class, $e->getMessage()); + } + + // Repeat transaction + return $this->transactionExecute($func, $arguments, $connectionRetries, $lockRetries); + } catch (DriverException $e) { // Some other SQL related exception + // Attempt to roll back, suppress any possible exceptions + try { + /** + * @var Connection $connection + */ + $connection = $this->lowerLayer->getConnection(); + $connection->rollBack(); + } catch (\Exception $eNotUsed) { + } + + // Set flag for "not in a transaction" + $this->setTransaction(false); + + // Throw DB exception for higher-up context to catch + throw DBDebug::createException(DBDriverException::class, DBInterface::class, $e->getMessage()); + } catch (\Exception | \Throwable $e) { // Other exception, throw it as is, we do not know how to deal with it + // Attempt to roll back, suppress any possible exceptions + try { + /** + * @var Connection $connection + */ + $connection = $this->lowerLayer->getConnection(); + $connection->rollBack(); + } catch (\Exception $eNotUsed) { + } + + // Set flag for "not in a transaction" + $this->setTransaction(false); + + // Throw exception again for higher-up context to catch + throw $e; + } + } + + /** + * @inheritDoc + */ + public function select($query, array $vars = []): DBSelectQueryInterface + { + return $this->internalCall(__FUNCTION__, \func_get_args(), $this->connectionRetries, $this->lockRetries); + } + + /** + * @inheritDoc + */ + public function fetch(DBSelectQueryInterface $selectQuery): ?array + { + return $this->internalCall(__FUNCTION__, \func_get_args(), $this->connectionRetries, $this->lockRetries); + } + + /** + * @inheritDoc + */ + public function clear(DBSelectQueryInterface $selectQuery): void + { + $this->internalCall(__FUNCTION__, \func_get_args(), $this->connectionRetries, $this->lockRetries); + } + + /** + * @inheritDoc + */ + public function fetchOne($query, array $vars = []): ?array + { + return $this->internalCall(__FUNCTION__, \func_get_args(), $this->connectionRetries, $this->lockRetries); + } + + /** + * @inheritDoc + */ + public function fetchAll($query, array $vars = []): array + { + return $this->internalCall(__FUNCTION__, \func_get_args(), $this->connectionRetries, $this->lockRetries); + } + + /** + * @inheritDoc + */ + public function insert(string $tableName, array $row = []): int + { + return $this->internalCall(__FUNCTION__, \func_get_args(), $this->connectionRetries, $this->lockRetries); + } + + /** + * @inheritDoc + */ + public function upsert(string $tableName, array $row = [], array $indexColumns = [], array $rowUpdates = []): int + { + return $this->internalCall(__FUNCTION__, \func_get_args(), $this->connectionRetries, $this->lockRetries); + } + + /** + * @inheritDoc + */ + public function update(array $query): int + { + return $this->internalCall(__FUNCTION__, \func_get_args(), $this->connectionRetries, $this->lockRetries); + } + + /** + * @inheritDoc + */ + public function delete(string $tableName, array $where = []): int + { + return $this->internalCall(__FUNCTION__, \func_get_args(), $this->connectionRetries, $this->lockRetries); + } + + /** + * @inheritDoc + */ + public function change(string $query, array $vars = []): int + { + return $this->internalCall(__FUNCTION__, \func_get_args(), $this->connectionRetries, $this->lockRetries); + } + + /** + * @inheritDoc + */ + public function lastInsertId($name = null): string + { + return $this->internalCall(__FUNCTION__, \func_get_args(), $this->connectionRetries, $this->lockRetries); + } + + /** + * Pass through all calls to lower layer, and just add try-catch blocks so we can + * catch and process connection and (dead)lock exceptions / repeat queries + * + * @param string $name + * @param array $arguments + * @param array $connectionRetries + * @param array $lockRetries + * @return mixed + * + * @throws DBException + */ + protected function internalCall(string $name, array $arguments, array $connectionRetries, array $lockRetries) + { + // Attempt to call the dbal function + try { + return $this->lowerLayer->$name(...$arguments); + } catch (ConnectionException $e) { + // If we are in a transaction we escalate it to transaction context + if ($this->lowerLayer->inTransaction()) { + throw $e; + } + + // Attempt to reconnect according to $connectionRetries + $connectionRetries = $this->attemptReconnect($connectionRetries); + + // Reconnecting was unsuccessful + if ($connectionRetries === false) { + throw DBDebug::createException(DBConnectionException::class, DBInterface::class, $e->getMessage()); + } + + // Repeat our function + return $this->internalCall($name, $arguments, $connectionRetries, $lockRetries); + } catch (DeadlockException | LockWaitTimeoutException $e) { + // If we are in a transaction we escalate it to transaction context + if ($this->lowerLayer->inTransaction()) { + throw $e; + } + + // We have exhaused all deadlock retries and it is time to give up + if (\count($lockRetries) === 0) { + throw DBDebug::createException(DBLockException::class, DBInterface::class, $e->getMessage()); + } + + // Wait for a certain amount of microseconds + \usleep(\array_shift($lockRetries)); + + // Repeat our function + return $this->internalCall($name, $arguments, $connectionRetries, $lockRetries); + } catch (DriverException $e) { // Some other SQL related exception + throw DBDebug::createException(DBDriverException::class, DBInterface::class, $e->getMessage()); + } + } + + /** + * Attempt to reconnect to DB server + * + * @param array $connectionRetries + * @return array|false + */ + protected function attemptReconnect(array $connectionRetries) + { + // No more attempts left - return false to report back + if (\count($connectionRetries) === 0) { + return false; + } + + // Wait for a certain amount of microseconds + \usleep(\array_shift($connectionRetries)); + + try { + /** + * @var Connection $connection + */ + $connection = $this->lowerLayer->getConnection(); + // Close connection and establish a new connection + $connection->close(); + $connection->connect(); + + // If we still do not have a connection we need to try again + if ($connection->ping() === false) { + return $this->attemptReconnect($connectionRetries); + } + } catch (ConnectionException $e) { // Connection could not be established - try again + return $this->attemptReconnect($connectionRetries); + } + + // Go back to the previous context with our new connection + return $connectionRetries; + } +} diff --git a/src/Doctrine/DBMySQLImplementation.php b/src/Doctrine/DBMySQLImplementation.php new file mode 100644 index 0000000..730f199 --- /dev/null +++ b/src/Doctrine/DBMySQLImplementation.php @@ -0,0 +1,70 @@ +structuredQueryConverter->buildChanges($rowUpdates, $queryValues); + } + + // Generate the insert query + $query = 'INSERT INTO ' . $this->quoteIdentifier($tableName) . + ' (' . (count($columnsForInsert) > 0 ? implode(',', $columnsForInsert) : '') . ') ' . + 'VALUES (' . (count($columnsForInsert) > 0 ? implode(',', $placeholdersForInsert) : '') . ') ' . + 'ON DUPLICATE KEY UPDATE ' . $updatePart; + + // Return 1 if a row was inserted, 2 if a row was updated, and 0 if there was no change + return $this->change($query, $queryValues); + } +} diff --git a/src/Doctrine/DBPostgreSQLImplementation.php b/src/Doctrine/DBPostgreSQLImplementation.php new file mode 100644 index 0000000..295b074 --- /dev/null +++ b/src/Doctrine/DBPostgreSQLImplementation.php @@ -0,0 +1,71 @@ +structuredQueryConverter->buildChanges($rowUpdates, $queryValues); + } + + // Generate the insert query + $query = 'INSERT INTO ' . $this->quoteIdentifier($tableName) . + ' (' . (count($columnsForInsert) > 0 ? implode(',', $columnsForInsert) : '') . ') ' . + 'VALUES (' . (count($columnsForInsert) > 0 ? implode(',', $placeholdersForInsert) : '') . ') ' . + 'ON CONFLICT (' . implode(',', array_map([$this, 'quoteIdentifier'], $indexColumns)) . ') ' . + 'DO UPDATE ' . $updatePart; + + // Return 1 if a row was inserted, 2 if a row was updated, and 0 if there was no change + return $this->change($query, $queryValues); + } +} diff --git a/src/Doctrine/DBSQLiteImplementation.php b/src/Doctrine/DBSQLiteImplementation.php new file mode 100644 index 0000000..ed7a8d1 --- /dev/null +++ b/src/Doctrine/DBSQLiteImplementation.php @@ -0,0 +1,12 @@ +statement = $statement; + $this->flattenFields = $flattenFields; + } + + /** + * @return ResultStatement + */ + public function getStatement(): ResultStatement + { + return $this->statement; + } + + /** + * @return bool + */ + public function hasFlattenFields(): bool + { + return $this->flattenFields; + } +} diff --git a/src/Exception/DBConnectionException.php b/src/Exception/DBConnectionException.php new file mode 100644 index 0000000..1be40a7 --- /dev/null +++ b/src/Exception/DBConnectionException.php @@ -0,0 +1,12 @@ +assertSame($sqlCmd, $dbE->getSqlCmd()); + $this->assertSame($sqlFile, $dbE->getSqlFile()); + $this->assertSame($sqlLine, $dbE->getSqlLine()); + $this->assertSame($message, $dbE->getMessage()); + $this->assertSame($code, $dbE->getCode()); + $this->assertSame($e, $dbE->getPrevious()); + } +} diff --git a/tests/DBInterfaceForTests.php b/tests/DBInterfaceForTests.php new file mode 100644 index 0000000..b10c4ba --- /dev/null +++ b/tests/DBInterfaceForTests.php @@ -0,0 +1,30 @@ +dbRawObject = \Mockery::mock(DBRawInterface::class)->makePartial(); + + // Lower layer mock, where we check the function calls + $this->dbLowerLayerObject = \Mockery::mock(DBPassToLowerLayerTrait::class)->makePartial(); + $this->dbLowerLayerObject->setLowerLayer($this->dbRawObject); + } + + public function testTransaction() + { + // Variables to pass to the function + $func = function ($a, $b, $c) { + return $a + $b + $c; + }; + $a = 3; + $b = 10; + $c = 107; + + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('transaction') + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andReturn(120); + + // Make the trait function call + $return = $this->dbLowerLayerObject->transaction($func, $a, $b, $c); + + // Check the result + $this->assertSame(120, $return); + } + + public function testInTransaction() + { + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('inTransaction') + ->andReturn(true); + + // Make the trait function call + $return = $this->dbLowerLayerObject->inTransaction(); + + // Check the result + $this->assertSame(true, $return); + } + + public function testSelect() + { + // Variables to pass to the function + $query = 'SELECT'; + $vars = [0, 3, 9]; + + // Select query result + $select = new DBSelectQueryForTests(); + + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('select') + ->with(\Mockery::mustBe($query), \Mockery::mustBe($vars)) + ->andReturn($select); + + // Make the trait function call + $return = $this->dbLowerLayerObject->select($query, $vars); + + // Check the result + $this->assertSame($select, $return); + } + + public function testFetch() + { + // Select query result + $select = new DBSelectQueryForTests(); + + // Expected return value + $expected = ['dada' => 5]; + + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('fetch') + ->with(\Mockery::mustBe($select)) + ->andReturn($expected); + + // Make the trait function call + $return = $this->dbLowerLayerObject->fetch($select); + + // Check the result + $this->assertSame($expected, $return); + } + + public function testClear() + { + // Select query result + $select = new DBSelectQueryForTests(); + + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('clear') + ->with(\Mockery::mustBe($select)); + + // Make the trait function call + $this->dbLowerLayerObject->clear($select); + + // Check the result + $this->assertTrue(true); + } + + public function testFetchOne() + { + // Variables to pass to the function + $query = 'SELECT'; + $vars = [0, 3, 9]; + + // Expected return value + $expected = ['dada' => 5]; + + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('fetchOne') + ->with(\Mockery::mustBe($query), \Mockery::mustBe($vars)) + ->andReturn($expected); + + // Make the trait function call + $return = $this->dbLowerLayerObject->fetchOne($query, $vars); + + // Check the result + $this->assertSame($expected, $return); + } + + public function testFetchAll() + { + // Variables to pass to the function + $query = 'SELECT'; + $vars = [0, 3, 9]; + + // Expected return value + $expected = [['dada' => 5]]; + + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('fetchAll') + ->with(\Mockery::mustBe($query), \Mockery::mustBe($vars)) + ->andReturn($expected); + + // Make the trait function call + $return = $this->dbLowerLayerObject->fetchAll($query, $vars); + + // Check the result + $this->assertSame($expected, $return); + } + + public function testInsert() + { + // Variables to pass to the function + $tableName = 'users'; + $row = [ + 'userId' => 1, + 'userName' => 'Liam', + 'createDate' => 1048309248, + ]; + + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('insert') + ->with(\Mockery::mustBe($tableName), \Mockery::mustBe($row)) + ->andReturn(7); + + // Make the trait function call + $return = $this->dbLowerLayerObject->insert($tableName, $row); + + // Check the result + $this->assertSame(7, $return); + } + + public function testUpsert() + { + // Variables to pass to the function + $tableName = 'users'; + $row = [ + 'userId' => 1, + 'userName' => 'Liam', + 'lastUpdate' => 1048309248, + ]; + $indexColumns = [ + 'userId', + ]; + $rowUpdates = [ + 'lastUpdate' => 1048309248, + ]; + + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('upsert') + ->with( + \Mockery::mustBe($tableName), + \Mockery::mustBe($row), + \Mockery::mustBe($indexColumns), + \Mockery::mustBe($rowUpdates) + ) + ->andReturn(7); + + // Make the trait function call + $return = $this->dbLowerLayerObject->upsert($tableName, $row, $indexColumns, $rowUpdates); + + // Check the result + $this->assertSame(7, $return); + } + + public function testUpdate() + { + // Variables to pass to the function + $query = [ + 'table' => 'dada', + 'fields' => [ + 'dadaism', + ], + ]; + + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('update') + ->with(\Mockery::mustBe($query)) + ->andReturn(7); + + // Make the trait function call + $return = $this->dbLowerLayerObject->update($query); + + // Check the result + $this->assertSame(7, $return); + } + + public function testDelete() + { + // Variables to pass to the function + $tableName = 'dadaism'; + $query = [ + 'table' => 'dada', + ]; + + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('delete') + ->with(\Mockery::mustBe($tableName), \Mockery::mustBe($query)) + ->andReturn(7); + + // Make the trait function call + $return = $this->dbLowerLayerObject->delete($tableName, $query); + + // Check the result + $this->assertSame(7, $return); + } + + public function testLastInsertId() + { + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('lastInsertId') + ->with(\Mockery::mustBe('dada')) + ->andReturn(5); + + // Make the trait function call + $return = $this->dbLowerLayerObject->lastInsertId('dada'); + + // Check the result + $this->assertSame('5', $return); + } + + public function testChange() + { + // Variables to pass to the function + $query = 'SELECT'; + $vars = [0, 3, 9]; + + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('change') + ->with(\Mockery::mustBe($query), \Mockery::mustBe($vars)) + ->andReturn(7); + + // Make the trait function call + $return = $this->dbLowerLayerObject->change($query, $vars); + + // Check the result + $this->assertSame(7, $return); + } + + public function testQuoteIdentifier() + { + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('quoteIdentifier') + ->with(\Mockery::mustBe('dada')) + ->andReturn('"dada"'); + + // Make the trait function call + $return = $this->dbLowerLayerObject->quoteIdentifier('dada'); + + // Check the result + $this->assertSame('"dada"', $return); + } + + public function testChangeTransaction() + { + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('setTransaction') + ->with(\Mockery::mustBe(true)); + + // Make the trait function call + $this->dbLowerLayerObject->setTransaction(true); + + // Check the result + $this->assertTrue(true); + } + + public function testGetConnection() + { + // Connection dummy class + $connection = new \stdClass(); + + // What we expect to be called with the lower layer + $this->dbRawObject + ->shouldReceive('getConnection') + ->andReturn($connection); + + // Make the trait function call + $return = $this->dbLowerLayerObject->getConnection(); + + // Check the result + $this->assertSame($connection, $return); + } +} diff --git a/tests/DBSelectQueryForTests.php b/tests/DBSelectQueryForTests.php new file mode 100644 index 0000000..88f7b67 --- /dev/null +++ b/tests/DBSelectQueryForTests.php @@ -0,0 +1,9 @@ +shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andReturn(173); + + // We only get the forwarding to lower layer if there is no transaction active + $lowerLayer + ->shouldReceive('inTransaction') + ->andReturn(false); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + // Do the transaction function + $result = $errorHandler->transaction($func, $a, $b, $c); + + // Check that we got back the transaction result + $this->assertEquals(173, $result); + } + + /** + * Test that the transaction is not forwarded to lower layer if a transaction is already active + */ + public function testTransactionWhenActiveTransactionExists() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // Example function to pass along + $func = function ($a, $b, $c) { + return $a + $b + $c; + }; + + // Example variables to pass along + $a = 5; + $b = 13; + $c = 155; + + // Transaction is reported as active, so we call the function directly, no lower layer + $lowerLayer + ->shouldReceive('inTransaction') + ->andReturn(true); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + // Do the transaction function + $result = $errorHandler->transaction($func, $a, $b, $c); + + // Check that we got back the transaction result + $this->assertEquals(173, $result); + } + + public function testSelectPassToLowerLayer() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + $selectQueryResult = new DBSelectQueryForTests(); + + $lowerLayer + ->shouldReceive('select') + ->once() + ->with('SELECT * FROM table') + ->andReturn($selectQueryResult); + + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + $result = $errorHandler->select('SELECT * FROM table'); + + $this->assertSame($selectQueryResult, $result); + } + + public function testFetchPassToLowerLayer() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + $selectQueryResult = new DBSelectQueryForTests(); + + $lowerLayer + ->shouldReceive('fetch') + ->once() + ->with($selectQueryResult) + ->andReturn(['dada' => '55']); + + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + $result = $errorHandler->fetch($selectQueryResult); + + $this->assertSame(['dada' => '55'], $result); + } + + public function testClearPassToLowerLayer() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + $selectQueryResult = new DBSelectQueryForTests(); + + $lowerLayer + ->shouldReceive('clear') + ->once() + ->with($selectQueryResult); + + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + $errorHandler->clear($selectQueryResult); + + $this->assertTrue(true); + } + + public function testFetchOnePassToLowerLayer() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + $lowerLayer + ->shouldReceive('fetchOne') + ->once() + ->with('SELECT * FROM table') + ->andReturn(['dada' => '55']); + + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + $result = $errorHandler->fetchOne('SELECT * FROM table'); + + $this->assertSame(['dada' => '55'], $result); + } + + public function testFetchAllPassToLowerLayer() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + $lowerLayer + ->shouldReceive('fetchAll') + ->once() + ->with('SELECT * FROM table') + ->andReturn([['dada' => '55'], ['dada' => 33]]); + + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + $result = $errorHandler->fetchAll('SELECT * FROM table'); + + $this->assertSame([['dada' => '55'], ['dada' => 33]], $result); + } + + public function testInsertPassToLowerLayer() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + $lowerLayer + ->shouldReceive('insert') + ->once() + ->with('tableName', [ + 'dada' => 33, + 'fufu' => true, + ]) + ->andReturn(33); + + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + $result = $errorHandler->insert('tableName', [ + 'dada' => 33, + 'fufu' => true, + ]); + + $this->assertSame(33, $result); + } + + public function testUpsertPassToLowerLayer() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + $lowerLayer + ->shouldReceive('upsert') + ->once() + ->with('tableName', [ + 'dada' => 33, + 'fufu' => true, + ], ['dada']) + ->andReturn(2); + + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + $result = $errorHandler->upsert('tableName', [ + 'dada' => 33, + 'fufu' => true, + ], ['dada']); + + $this->assertSame(2, $result); + } + + public function testUpdatePassToLowerLayer() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + $lowerLayer + ->shouldReceive('update') + ->once() + ->with([ + 'table' => 'blobs.aa_sexy', + 'changes' => [ + 'anyfieldname' => 'nicevalue', + ], + 'where' => [ + 'blabla' => 5, + ], + 'limit' => 13, + ]) + ->andReturn(7); + + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + $result = $errorHandler->update([ + 'table' => 'blobs.aa_sexy', + 'changes' => [ + 'anyfieldname' => 'nicevalue', + ], + 'where' => [ + 'blabla' => 5, + ], + 'limit' => 13, + ]); + + $this->assertSame(7, $result); + } + + public function testDeletePassToLowerLayer() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + $lowerLayer + ->shouldReceive('delete') + ->once() + ->with('tableName', [ + 'dada' => 33, + 'fufu' => true, + ]) + ->andReturn(6); + + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + $result = $errorHandler->delete('tableName', [ + 'dada' => 33, + 'fufu' => true, + ]); + + $this->assertSame(6, $result); + } + + public function testChangePassToLowerLayer() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + $lowerLayer + ->shouldReceive('change') + ->once() + ->with( + 'UPDATE "blobs"."aa_sexy" SET "anyfieldname"=?,"nullentry"=? WHERE "blabla"=?', + ['nicevalue', null, 5] + ) + ->andReturn(9); + + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + $result = $errorHandler->change( + 'UPDATE "blobs"."aa_sexy" SET "anyfieldname"=?,"nullentry"=? WHERE "blabla"=?', + ['nicevalue', null, 5] + ); + + $this->assertSame(9, $result); + } + + public function testLastInsertIdPassToLowerLayer() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + $lowerLayer + ->shouldReceive('lastInsertId') + ->once() + ->with('name') + ->andReturn('89'); + + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + $result = $errorHandler->lastInsertId('name'); + + $this->assertSame('89', $result); + } + + public function testRedoTransactionAfterDeadlock() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // Example function to pass along + $func = function ($a, $b, $c) { + return $a + $b + $c; + }; + + // Example variables to pass along + $a = 5; + $b = 13; + $c = 155; + + // We only get the forwarding to lower layer if there is no transaction active + $lowerLayer + ->shouldReceive('inTransaction') + ->withNoArgs() + ->andReturn(false); + + // The call we are expecting to the lower layer + $lowerLayer + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andThrow( + new DeadlockException( + 'Deadlock occured!', + new PDOException(new \PDOException('pdo deadlock exception')) + ) + ); + + $lowerLayer + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andReturn(173); + + $connection = \Mockery::mock(Connection::class); + + $lowerLayer + ->shouldReceive('getConnection') + ->once() + ->withNoArgs() + ->andReturn($connection); + + $connection + ->shouldReceive('rollBack') + ->once() + ->withNoArgs() + ->andThrow(new \Exception('some random rollback exception')); + + $lowerLayer + ->shouldReceive('setTransaction') + ->once() + ->with(false); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + // Do the transaction function + $result = $errorHandler->transaction($func, $a, $b, $c); + + // Check that we got back the transaction result + $this->assertEquals(173, $result); + } + + public function testRedoTransactionAfterConnectionProblem() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // Example function to pass along + $func = function ($a, $b, $c) { + return $a + $b + $c; + }; + + // Example variables to pass along + $a = 5; + $b = 13; + $c = 155; + + // We only get the forwarding to lower layer if there is no transaction active + $lowerLayer + ->shouldReceive('inTransaction') + ->withNoArgs() + ->andReturn(false); + + // The call we are expecting to the lower layer + $lowerLayer + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andThrow( + new ConnectionException( + 'Connection lost', + new PDOException(new \PDOException('MySQL server has gone away')) + ) + ); + + $lowerLayer + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andReturn(173); + + $connection = \Mockery::mock(Connection::class); + + $lowerLayer + ->shouldReceive('getConnection') + ->once() + ->withNoArgs() + ->andReturn($connection); + + $connection + ->shouldReceive('rollBack') + ->once() + ->withNoArgs() + ->andThrow(new \Exception('some random rollback exception')); + + $connection + ->shouldReceive('close') + ->once() + ->withNoArgs(); + + $connection + ->shouldReceive('connect') + ->once() + ->withNoArgs(); + + $connection + ->shouldReceive('ping') + ->once() + ->withNoArgs() + ->andReturn(true); + + $lowerLayer + ->shouldReceive('setTransaction') + ->once() + ->with(false); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + // Do the transaction function + $result = $errorHandler->transaction($func, $a, $b, $c); + + // Check that we got back the transaction result + $this->assertEquals(173, $result); + } + + public function testRedoTransactionAfterConnectionProblemMultipleAttempts() + { + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // Example function to pass along + $func = function ($a, $b, $c) { + return $a + $b + $c; + }; + + // Example variables to pass along + $a = 5; + $b = 13; + $c = 155; + + // We only get the forwarding to lower layer if there is no transaction active + $lowerLayer + ->shouldReceive('inTransaction') + ->withNoArgs() + ->andReturn(false); + + // The call we are expecting to the lower layer + $lowerLayer + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andThrow( + new ConnectionException( + 'Connection lost', + new PDOException(new \PDOException('MySQL server has gone away')) + ) + ); + + $lowerLayer + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andReturn(173); + + $connection = \Mockery::mock(Connection::class); + + $lowerLayer + ->shouldReceive('getConnection') + ->once() + ->withNoArgs() + ->andReturn($connection); + + $connection + ->shouldReceive('rollBack') + ->once() + ->withNoArgs() + ->andThrow(new \Exception('some random rollback exception')); + + $connection + ->shouldReceive('close') + ->times(3) + ->withNoArgs(); + + $connection + ->shouldReceive('connect') + ->times(3) + ->withNoArgs(); + + $connection + ->shouldReceive('ping') + ->once() + ->withNoArgs() + ->andReturn(false); + + $connection + ->shouldReceive('ping') + ->once() + ->withNoArgs() + ->andThrow( + new ConnectionException( + 'Connection lost', + new PDOException(new \PDOException('MySQL server has gone away')) + ) + ); + + $connection + ->shouldReceive('ping') + ->once() + ->withNoArgs() + ->andReturn(true); + + $lowerLayer + ->shouldReceive('setTransaction') + ->once() + ->with(false); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + // Do the transaction function + $result = $errorHandler->transaction($func, $a, $b, $c); + + // Check that we got back the transaction result + $this->assertEquals(173, $result); + } + + public function testExceptionNoRetriesTransactionAfterDeadlock() + { + $this->expectException(DBLockException::class); + + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // Example function to pass along + $func = function ($a, $b, $c) { + return $a + $b + $c; + }; + + // Example variables to pass along + $a = 5; + $b = 13; + $c = 155; + + // We only get the forwarding to lower layer if there is no transaction active + $lowerLayer + ->shouldReceive('inTransaction') + ->withNoArgs() + ->andReturn(false); + + // The call we are expecting to the lower layer + $lowerLayer + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andThrow( + new DeadlockException( + 'Deadlock occured!', + new PDOException(new \PDOException('pdo deadlock exception')) + ) + ); + + $connection = \Mockery::mock(Connection::class); + + $lowerLayer + ->shouldReceive('getConnection') + ->once() + ->withNoArgs() + ->andReturn($connection); + + $connection + ->shouldReceive('rollBack') + ->once() + ->withNoArgs(); + + $lowerLayer + ->shouldReceive('setTransaction') + ->once() + ->with(false); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + $errorHandler->setLockRetries([]); + + // Do the transaction function + $errorHandler->transaction($func, $a, $b, $c); + } + + public function testExceptionNoRetriesTransactionAfterConnectionProblem() + { + $this->expectException(DBConnectionException::class); + + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // Example function to pass along + $func = function ($a, $b, $c) { + return $a + $b + $c; + }; + + // Example variables to pass along + $a = 5; + $b = 13; + $c = 155; + + // We only get the forwarding to lower layer if there is no transaction active + $lowerLayer + ->shouldReceive('inTransaction') + ->withNoArgs() + ->andReturn(false); + + // The call we are expecting to the lower layer + $lowerLayer + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andThrow( + new ConnectionException( + 'Connection lost', + new PDOException(new \PDOException('MySQL server has gone away')) + ) + ); + + $lowerLayer + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andReturn(173); + + $connection = \Mockery::mock(Connection::class); + + $lowerLayer + ->shouldReceive('getConnection') + ->once() + ->withNoArgs() + ->andReturn($connection); + + $connection + ->shouldReceive('rollBack') + ->once() + ->withNoArgs(); + + $lowerLayer + ->shouldReceive('setTransaction') + ->once() + ->with(false); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + $errorHandler->setConnectionRetries([]); + + // Do the transaction function + $errorHandler->transaction($func, $a, $b, $c); + } + + public function testExceptionFromDriverLikeBadSQL() + { + $this->expectException(DBDriverException::class); + + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // Example function to pass along + $func = function ($a, $b, $c) { + return $a + $b + $c; + }; + + // Example variables to pass along + $a = 5; + $b = 13; + $c = 155; + + // We only get the forwarding to lower layer if there is no transaction active + $lowerLayer + ->shouldReceive('inTransaction') + ->withNoArgs() + ->andReturn(false); + + // The call we are expecting to the lower layer + $lowerLayer + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andThrow( + new DriverException( + 'Connection lost', + new PDOException(new \PDOException('MySQL server has gone away')) + ) + ); + + $connection = \Mockery::mock(Connection::class); + + $lowerLayer + ->shouldReceive('getConnection') + ->once() + ->withNoArgs() + ->andReturn($connection); + + $connection + ->shouldReceive('rollBack') + ->once() + ->withNoArgs() + ->andThrow(new \Exception('some rollback exception')); + + $lowerLayer + ->shouldReceive('setTransaction') + ->once() + ->with(false); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + // Do the transaction function + $errorHandler->transaction($func, $a, $b, $c); + } + + public function testUnhandledException() + { + $this->expectException(\InvalidArgumentException::class); + + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // Example function to pass along + $func = function ($a, $b, $c) { + return $a + $b + $c; + }; + + // Example variables to pass along + $a = 5; + $b = 13; + $c = 155; + + // We only get the forwarding to lower layer if there is no transaction active + $lowerLayer + ->shouldReceive('inTransaction') + ->withNoArgs() + ->andReturn(false); + + // The call we are expecting to the lower layer + $lowerLayer + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($func), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andThrow( + new \InvalidArgumentException('some weird exception') + ); + + $connection = \Mockery::mock(Connection::class); + + $lowerLayer + ->shouldReceive('getConnection') + ->once() + ->withNoArgs() + ->andReturn($connection); + + $connection + ->shouldReceive('rollBack') + ->once() + ->withNoArgs() + ->andThrow(new \Exception('some rollback exception')); + + $lowerLayer + ->shouldReceive('setTransaction') + ->once() + ->with(false); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + // Do the transaction function + $errorHandler->transaction($func, $a, $b, $c); + } + + public function testExceptionSelectWithinTransactionDeadlock() + { + $this->expectException(DeadlockException::class); + + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // The call we are expecting to the lower layer + $lowerLayer + ->shouldReceive('select') + ->once() + ->with('SELECT * FROM table') + ->andThrow( + new DeadlockException( + 'Deadlock occured!', + new PDOException(new \PDOException('pdo deadlock exception')) + ) + ); + + $lowerLayer + ->shouldReceive('inTransaction') + ->once() + ->withNoArgs() + ->andReturn(true); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + $errorHandler->setLockRetries([1]); + + // Do the transaction function + $errorHandler->select('SELECT * FROM table'); + } + + public function testExceptionNoRetriesSelectAfterDeadlock() + { + $this->expectException(DBLockException::class); + + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // The call we are expecting to the lower layer + $lowerLayer + ->shouldReceive('select') + ->twice() + ->with('SELECT * FROM table') + ->andThrow( + new DeadlockException( + 'Deadlock occured!', + new PDOException(new \PDOException('pdo deadlock exception')) + ) + ); + + $lowerLayer + ->shouldReceive('inTransaction') + ->once() + ->withNoArgs() + ->andReturn(false); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + $errorHandler->setLockRetries([1]); + + // Do the transaction function + $errorHandler->select('SELECT * FROM table'); + } + + public function testExceptionSelectWithinTransactionConnectionProblem() + { + $this->expectException(ConnectionException::class); + + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // The call we are expecting to the lower layer + $lowerLayer + ->shouldReceive('select') + ->once() + ->with('SELECT * FROM table') + ->andThrow( + new ConnectionException( + 'Connection lost', + new PDOException(new \PDOException('MySQL server has gone away')) + ) + ); + + $lowerLayer + ->shouldReceive('inTransaction') + ->once() + ->withNoArgs() + ->andReturn(true); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + $errorHandler->setConnectionRetries([1]); + + // Do the transaction function + $errorHandler->select('SELECT * FROM table'); + } + + public function testExceptionNoRetriesSelectAfterConnectionProblem() + { + $this->expectException(DBConnectionException::class); + + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // The call we are expecting to the lower layer + $lowerLayer + ->shouldReceive('select') + ->twice() + ->with('SELECT * FROM table') + ->andThrow( + new ConnectionException( + 'Connection lost', + new PDOException(new \PDOException('MySQL server has gone away')) + ) + ); + + $lowerLayer + ->shouldReceive('inTransaction') + ->once() + ->withNoArgs() + ->andReturn(false); + + $connection = \Mockery::mock(Connection::class); + + $lowerLayer + ->shouldReceive('getConnection') + ->once() + ->withNoArgs() + ->andReturn($connection); + + $connection + ->shouldReceive('close') + ->once() + ->withNoArgs(); + + $connection + ->shouldReceive('connect') + ->once() + ->withNoArgs(); + + $connection + ->shouldReceive('ping') + ->once() + ->withNoArgs() + ->andReturn(true); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + $errorHandler->setConnectionRetries([1]); + + // Do the transaction function + $errorHandler->select('SELECT * FROM table'); + } + + public function testExceptionSelectFromDriver() + { + $this->expectException(DBDriverException::class); + + // Lower layer mock + $lowerLayer = \Mockery::mock(DBRawInterface::class); + + // The call we are expecting to the lower layer + $lowerLayer + ->shouldReceive('select') + ->once() + ->with('SELECT * FROM table') + ->andThrow( + new DriverException( + 'Connection lost', + new PDOException(new \PDOException('MySQL server has gone away')) + ) + ); + + // Error handler instantiation + $errorHandler = new DBErrorHandler(); + $errorHandler->setLowerLayer($lowerLayer); + + // Do the transaction function + $errorHandler->select('SELECT * FROM table'); + } +} diff --git a/tests/DoctrineImplementationTest.php b/tests/DoctrineImplementationTest.php new file mode 100644 index 0000000..b10c527 --- /dev/null +++ b/tests/DoctrineImplementationTest.php @@ -0,0 +1,2008 @@ +connection = \Mockery::mock(Connection::class); + + // Implementation class + $this->db = \Mockery::mock(DBAbstractImplementation::class)->makePartial(); + $this->db->__construct($this->connection); + + // Make sure quoteIdentifier works as expected + $this->connection + ->shouldReceive('quoteIdentifier') + ->andReturnUsing(function ($identifier) { + if (strpos($identifier, ".") !== false) { + $parts = array_map( + function ($p) { + return '"' . str_replace('"', '""', $p) . '"'; + }, + explode(".", $identifier) + ); + + return implode(".", $parts); + } + + return '"' . str_replace('"', '""', $identifier) . '"'; + }); + } + + /** + * Check that we correctly return the connection object + */ + public function testConnection() + { + $this->assertSame($this->connection, $this->db->getConnection()); + } + + /** + * Check correct return values for transaction bool + */ + public function testInTransaction() + { + $this->assertSame(false, $this->db->inTransaction()); + $this->db->setTransaction(true); + $this->assertSame(true, $this->db->inTransaction()); + $this->db->setTransaction(false); + $this->assertSame(false, $this->db->inTransaction()); + } + + public function testTransaction() + { + // Make sure no transaction is running at the beginning + $this->assertSame(false, $this->db->inTransaction()); + + // Expect a "beginTransaction" call + $this->connection + ->shouldReceive('beginTransaction') + ->once(); + + // Actual transaction call + $result = $this->db->transaction(function () { + // Make sure we are now in a transaction + $this->assertSame(true, $this->db->inTransaction()); + + // A commit is expected next + $this->connection + ->shouldReceive('commit') + ->once(); + + // Return value to make sure it is passed along + return 'blabla'; + }); + + // Make sure the transaction has ended with the correct result + $this->assertSame('blabla', $result); + $this->assertSame(false, $this->db->inTransaction()); + } + + public function testTransactionWithinTransaction() + { + // Set transaction to "yes" + $this->db->setTransaction(true); + + // Actual transaction call + $result = $this->db->transaction(function () { + // Make sure we are now in a transaction + $this->assertSame(true, $this->db->inTransaction()); + + // Return value to make sure it is passed along + return 'blabla'; + }); + + // Make sure the transaction has ended with the correct result + $this->assertSame('blabla', $result); + $this->assertSame(true, $this->db->inTransaction()); + } + + public function testSelect() + { + // Query parameters + $query = 'SELECT blabla FROM yudihui'; + $vars = [0, 'dada', 3.5]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + $result = $this->db->select($query, $vars); + + // Make sure the query has ended with the correct result + $this->assertEquals(new DBSelectQuery($statement), $result); + } + + public function testFetch() + { + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // Select query object + $selectQuery = new DBSelectQuery($statement); + + // Return value from fetch + $returnValue = ['fieldName' => 'dada']; + + // Fetch result set + $statement + ->shouldReceive('fetch') + ->once() + ->with(\Mockery::mustBe(FetchMode::ASSOCIATIVE)) + ->andReturn($returnValue); + + // Make the fetch call + $result = $this->db->fetch($selectQuery); + + // Make sure the query has ended with the correct result + $this->assertEquals($returnValue, $result); + } + + public function testClear() + { + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // Select query object + $selectQuery = new DBSelectQuery($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Make the fetch call + $this->db->clear($selectQuery); + + // Make sure the query has ended with the correct result + $this->assertTrue(true); + } + + public function testFetchOne() + { + // Query parameters + $query = 'SELECT blabla FROM yudihui'; + $vars = [0, 'dada', 3.5]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // Return value from fetch + $returnValue = ['dada']; + + // Fetch result set + $statement + ->shouldReceive('fetch') + ->once() + ->with(\Mockery::mustBe(FetchMode::ASSOCIATIVE)) + ->andReturn($returnValue); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('closeCursor') + ->once(); + + $result = $this->db->fetchOne($query, $vars); + + // Make sure the query has ended with the correct result + $this->assertEquals($returnValue, $result); + } + + public function testFetchAll() + { + // Query parameters + $query = 'SELECT blabla FROM yudihui'; + $vars = [0, 'dada', 3.5]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // Return value from fetch + $returnValue = ['dada', 'mumu', 'hihihi']; + + // Fetch result set + $statement + ->shouldReceive('fetchAll') + ->once() + ->with(\Mockery::mustBe(FetchMode::ASSOCIATIVE)) + ->andReturn($returnValue); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('closeCursor') + ->once(); + + $result = $this->db->fetchAll($query, $vars); + + // Make sure the query has ended with the correct result + $this->assertEquals($returnValue, $result); + } + + public function testInsert() + { + // Expected query and parameters + $query = 'INSERT INTO "tableName" ("id","name","lastUpdate") VALUES (?,?,?)'; + $vars = [5, 'Dada', 43535]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // "RowCount" call on doctrine result statement + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(5); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Insert query call + $result = $this->db->insert('tableName', [ + 'id' => 5, + 'name' => 'Dada', + 'lastUpdate' => 43535, + ]); + + // Make sure the query has ended with the correct result + $this->assertEquals(5, $result); + } + + public function testLastInsertId() + { + // Query parameters + $name = 'yayyay'; + + // "lastInsertId" call to doctrine connection + $this->connection + ->shouldReceive('lastInsertId') + ->once() + ->with(\Mockery::mustBe($name)) + ->andReturn(88); + + $result = $this->db->lastInsertId($name); + + // Make sure the query has ended with the correct result + $this->assertEquals(88, $result); + } + + public function testUpdate() + { + // Query we use to test the update + $query = [ + 'changes' => [ + 'boringfield' => 33, + ], + 'table' => 'dada', + 'where' => [ + 'blabla' => 5, + ], + ]; + + // What we convert the query into + $queryAsString = 'UPDATE "dada" SET "boringfield"=? WHERE "blabla"=?'; + $vars = [33, 5]; + + // Change call within the db class + $this->db + ->shouldReceive('change') + ->once() + ->with(\Mockery::mustBe($queryAsString), \Mockery::mustBe($vars)) + ->andReturn(33); + + // Call the update + $results = $this->db->update($query); + + // Make sure we received the right results + $this->assertSame(33, $results); + } + + public function testChange() + { + // Expected query and parameters + $query = 'INSERT INTO "tableName" ("id","name","lastUpdate") VALUES (?,?,?)'; + $vars = [5, 'Dada', 43535]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // "RowCount" call on doctrine result statement + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(5); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Insert query call + $result = $this->db->change('INSERT INTO "tableName" ("id","name","lastUpdate") VALUES (?,?,?)', [ + 5, + 'Dada', + 43535, + ]); + + // Make sure the query has ended with the correct result + $this->assertEquals(5, $result); + } + + public function testChangeSimple() + { + // Expected query and parameters + $query = 'INSERT INTO "tableName" ("id","name","lastUpdate") VALUES (5,"Dada",4534)'; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe([])); + + // "RowCount" call on doctrine result statement + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(5); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Insert query call + $result = $this->db->change('INSERT INTO "tableName" ("id","name","lastUpdate") VALUES (5,"Dada",4534)'); + + // Make sure the query has ended with the correct result + $this->assertEquals(5, $result); + } + + public function testDelete() + { + // Expected query and parameters + $query = 'DELETE FROM "tablename" WHERE "mamamia"=? AND "fumbal" IN (?,?,?)'; + $vars = [13, 3, 5, 9]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // "RowCount" call on doctrine result statement + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(5); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Insert query call + $result = $this->db->delete('tablename', [ + 'mamamia' => $vars[0], + 'fumbal' => [$vars[1], $vars[2], $vars[3]], + ]); + + // Make sure the query has ended with the correct result + $this->assertEquals(5, $result); + } + + public function testDeleteSimple() + { + // Expected query and parameters + $query = 'DELETE FROM "tablename" WHERE ("mamamia"=1)'; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe([])); + + // "RowCount" call on doctrine result statement + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(5); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Insert query call + $result = $this->db->delete('tablename', [ + '"mamamia"=1', + ]); + + // Make sure the query has ended with the correct result + $this->assertEquals(5, $result); + } + + public function testNoLowerLayer() + { + // Expect an InvalidArgument exception + $this->expectException(\LogicException::class); + + // No lower layer may be set with the actual implementation + $this->db->setLowerLayer(\Mockery::mock(DBAbstractImplementation::class)); + } + + public function testNoSelectObject1() + { + $this->expectException(DBInvalidOptionException::class); + + // Make a mock of just the interface, although we need a DBSelectQuery object + $selectQueryInterface = \Mockery::mock(DBSelectQueryInterface::class); + + // No valid DBSelectQuery object - should throw an exception + $this->db->fetch($selectQueryInterface); + } + + public function testNoSelectObject2() + { + $this->expectException(DBInvalidOptionException::class); + + // Make a mock of just the interface, although we need a DBSelectQuery object + $selectQueryInterface = \Mockery::mock(DBSelectQueryInterface::class); + + // No valid DBSelectQuery object - should throw an exception + $this->db->clear($selectQueryInterface); + } + + public function testInsertNoTableName() + { + $this->expectException(DBInvalidOptionException::class); + + // Invalid insert statement without table name + $this->db->insert('', [ + 'blabla' => 'dada', + ]); + } + + public function testDeleteNoTableName() + { + $this->expectException(DBInvalidOptionException::class); + + // Invalid insert statement without table name + $this->db->delete('', [ + 'blabla' => 'dada', + ]); + } + + public function testSelectStructuredSimple() + { + // Query parameters + $query = 'SELECT "blabla" FROM "yudihui" WHERE "lala"=?'; + $vars = [5]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + $result = $this->db->select([ + 'fields' => [ + 'blabla', + ], + 'tables' => [ + 'yudihui', + ], + 'where' => [ + 'lala' => 5, + ], + ]); + + // Make sure the query has ended with the correct result + $this->assertEquals(new DBSelectQuery($statement), $result); + + $result = $this->db->select([ + 'field' => 'blabla', + 'table' => 'yudihui', + 'where' => [ + 'lala' => 5, + ], + ]); + + // Make sure the query has ended with the correct result + $this->assertEquals(new DBSelectQuery($statement), $result); + } + + public function testSelectStructuredCatchAll() + { + // Query parameters + $query = 'SELECT "a".*,"b"."lala" FROM "yudihui" "a","ahoi" "b" WHERE "a"."lala"=?'; + $vars = [5]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + $result = $this->db->select([ + 'fields' => [ + ':a:.*', + ':b.lala:', + ], + 'tables' => [ + 'yudihui a', + ':ahoi: :b:', + ], + 'where' => [ + 'a.lala' => 5, + ], + ]); + + // Make sure the query has ended with the correct result + $this->assertEquals(new DBSelectQuery($statement), $result); + + // Query parameters + $query = 'SELECT a.*,"b"."lala" FROM "yudihui" "a","ahoi" "b" WHERE "a"."lala"=?'; + $vars = [5]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + $result = $this->db->select([ + 'fields' => [ + 'a.*', + 'b.lala', + ], + 'tables' => [ + 'yudihui a', + 'ahoi b', + ], + 'where' => [ + 'a.lala' => 5, + ], + ]); + + // Make sure the query has ended with the correct result + $this->assertEquals(new DBSelectQuery($statement), $result); + } + + public function testSelectStructuredCatchAllNoFields() + { + // Query parameters + $query = 'SELECT * FROM "yudihui" "a","ahoi" "b" WHERE "a"."lala"=?'; + $vars = [5]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + $result = $this->db->select([ + 'tables' => [ + 'yudihui a', + ':ahoi: :b:', + ], + 'where' => [ + 'a.lala' => 5, + ], + ]); + + // Make sure the query has ended with the correct result + $this->assertEquals(new DBSelectQuery($statement), $result); + } + + public function testSelectStructuredNoWhere() + { + // Query parameters + $query = 'SELECT * FROM "yudihui" "a","ahoi" "b"'; + $vars = []; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + $result = $this->db->select([ + 'tables' => [ + 'yudihui a', + ':ahoi: :b:', + ], + 'where' => [], + ]); + + // Make sure the query has ended with the correct result + $this->assertEquals(new DBSelectQuery($statement), $result); + } + + public function testSelectStructuredComplicated() + { + // Query parameters + $query = 'SELECT "fufumama","b"."lalala","a"."setting_value" AS "result",' . + '("a"."setting_value"+"b"."blabla_value") AS "result2" ' . + 'FROM "blobs"."aa_sexy" "a","blobs"."aa_blubli" "b" ' . + 'LEFT JOIN "blobs"."aa_blubla" "c" ON ("c"."field" = "b"."field5" AND "b"."sexy" = ?) ' . + 'WHERE ' . + '("a"."field" = "b"."field") ' . + 'AND "setting_id"=? ' . + 'AND "boring_field_name" IN (?,?,?,?) ' . + 'AND "another_boring_name" IS NULL ' . + 'AND "boolean_field"=? ' . + 'GROUP BY "a"."field" ' . + 'ORDER BY "a"."field" DESC,"a"."field" + "b"."field" ASC'; + $vars = [5, 'orders_xml_override', 5, 3, 8, 13, 1]; + + // Emulation of the Doctrine platform class + $platform = \Mockery::mock(AbstractPlatform::class)->makePartial(); + + // Return the platform if it is asked for by the connection + $this->connection + ->shouldReceive('getDatabasePlatform') + ->once() + ->andReturn($platform); + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query . ' LIMIT 10 OFFSET 5')) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + $result = $this->db->select([ + 'fields' => [ + 'fufumama', + 'b.lalala', + 'result' => 'a.setting_value', + 'result2' => ':a.setting_value:+:b.blabla_value:', + ], + 'tables' => [ + 'blobs.aa_sexy a', + ':blobs.aa_blubli: :b: ' . + 'LEFT JOIN :blobs.aa_blubla: :c: ON (:c.field: = :b.field5: AND :b.sexy: = ?)' => 5, + ], + 'where' => [ + ':a.field: = :b.field:', + 'setting_id' => 'orders_xml_override', + 'boring_field_name' => [5, 3, 8, 13], + 'another_boring_name' => null, + 'boolean_field' => true, + ], + 'group' => [ + 'a.field', + ], + 'order' => [ + 'a.field' => 'DESC', + ':a.field: + :b.field:' + ], + 'limit' => 10, + 'offset' => 5, + ]); + + // Make sure the query has ended with the correct result + $this->assertEquals(new DBSelectQuery($statement), $result); + } + + public function testFetchOneStructured() + { + // Query parameters + $query = 'SELECT "user_agent_id" AS "id","user_agent_hash" AS "hash" ' . + 'FROM "blobs"."aa_stats_user_agents" WHERE "user_agent_hash"=? LIMIT 1'; + $vars = ['Mozilla']; + + // Structured query to test + $structuredQuery = [ + 'fields' => [ + 'id' => 'user_agent_id', + 'hash' => 'user_agent_hash', + ], + 'tables' => [ + 'blobs.aa_stats_user_agents', + ], + 'where' => [ + 'user_agent_hash' => 'Mozilla', + ], + ]; + + // Emulation of the Doctrine platform class + $platform = \Mockery::mock(AbstractPlatform::class)->makePartial(); + + // Return the platform if it is asked for by the connection + $this->connection + ->shouldReceive('getDatabasePlatform') + ->once() + ->andReturn($platform); + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // Return value from fetch + $returnValue = ['id' => '5', 'hash' => 'fhsdkj']; + + // Fetch result set + $statement + ->shouldReceive('fetch') + ->once() + ->with(\Mockery::mustBe(FetchMode::ASSOCIATIVE)) + ->andReturn($returnValue); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('closeCursor') + ->once(); + + $result = $this->db->fetchOne($structuredQuery); + + // Make sure the query has ended with the correct result + $this->assertEquals($returnValue, $result); + } + + public function testFetchOneStructuredFlattened() + { + // Query parameters + $query = 'SELECT "user_agent_id" AS "id","user_agent_hash" AS "hash" ' . + 'FROM "blobs"."aa_stats_user_agents" WHERE "user_agent_hash"=? LIMIT 1'; + $vars = ['Mozilla']; + + // Structured query to test + $structuredQuery = [ + 'fields' => [ + 'id' => 'user_agent_id', + 'hash' => 'user_agent_hash', + ], + 'tables' => [ + 'blobs.aa_stats_user_agents', + ], + 'where' => [ + 'user_agent_hash' => 'Mozilla', + ], + 'flattenFields' => true, + ]; + + // Emulation of the Doctrine platform class + $platform = \Mockery::mock(AbstractPlatform::class)->makePartial(); + + // Return the platform if it is asked for by the connection + $this->connection + ->shouldReceive('getDatabasePlatform') + ->once() + ->andReturn($platform); + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // Return value from fetch + $returnValue = ['id' => '5', 'hash' => 'fhsdkj']; + + // Fetch result set + $statement + ->shouldReceive('fetch') + ->once() + ->with(\Mockery::mustBe(FetchMode::ASSOCIATIVE)) + ->andReturn($returnValue); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('closeCursor') + ->once(); + + $result = $this->db->fetchOne($structuredQuery); + + // Make sure the query has ended with the correct result + $this->assertEquals(\array_values($returnValue), $result); + } + + public function testFetchAllStructured() + { + // Query parameters + $query = 'SELECT "user_agent_id" AS "id","user_agent_hash" AS "hash" ' . + 'FROM "blobs"."aa_stats_user_agents" WHERE "user_agent_hash"=?'; + $vars = ['Mozilla']; + + // Structured query to test + $structuredQuery = [ + 'fields' => [ + 'id' => 'user_agent_id', + 'hash' => 'user_agent_hash', + ], + 'tables' => [ + 'blobs.aa_stats_user_agents', + ], + 'where' => [ + 'user_agent_hash' => 'Mozilla', + ], + ]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // Return value from fetch + $returnValue = [['id' => '5', 'hash' => 'fhsdkj']]; + + // Fetch result set + $statement + ->shouldReceive('fetchAll') + ->once() + ->with(\Mockery::mustBe(FetchMode::ASSOCIATIVE)) + ->andReturn($returnValue); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('closeCursor') + ->once(); + + $result = $this->db->fetchAll($structuredQuery); + + // Make sure the query has ended with the correct result + $this->assertEquals($returnValue, $result); + } + + public function testFetchAllStructuredFlattened() + { + // Query parameters + $query = 'SELECT "user_agent_id" AS "id","user_agent_hash" AS "hash" ' . + 'FROM "blobs"."aa_stats_user_agents" WHERE "user_agent_hash"=?'; + $vars = ['Mozilla']; + + // Structured query to test + $structuredQuery = [ + 'fields' => [ + 'id' => 'user_agent_id', + 'hash' => 'user_agent_hash', + ], + 'tables' => [ + 'blobs.aa_stats_user_agents', + ], + 'where' => [ + 'user_agent_hash' => 'Mozilla', + ], + 'flattenFields' => true, + ]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // Return value from fetch + $returnValue = [['id' => '5', 'hash' => 'fhsdkj']]; + + // Fetch result set + $statement + ->shouldReceive('fetchAll') + ->once() + ->with(\Mockery::mustBe(FetchMode::ASSOCIATIVE)) + ->andReturn($returnValue); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('closeCursor') + ->once(); + + $result = $this->db->fetchAll($structuredQuery); + + // Make sure the query has ended with the correct result + $this->assertEquals(['5', 'fhsdkj'], $result); + } + + public function testFetchOneStructured2() + { + // Query parameters + $query = 'SELECT "c"."cart_id","c"."checkout_step","s"."session_id","s"."user_id","s"."domain" ' . + 'FROM ' . '"carts"' . ' "c",' . '"sessions"' . ' "s" ' . + 'WHERE ("c"."session_id" = "s"."session_id") ' . + 'AND "c"."cart_id"=? ' . + 'AND "s"."session_id"=? ' . + 'AND "s"."session_start"=? ' . + 'AND "c"."create_date"=? ' . + 'AND "s"."domain"=? ' . + 'AND ("s"."user_id" <= 0) ' . + 'LIMIT 1 FOR UPDATE'; + $vars = [5, 'aagdhf', 13, 19, 'example.com']; + + // Structured query to test + $structuredQuery = [ + 'tables' => [ + ':carts: :c:', + 'sessions s', + ], + 'fields' => [ + 'c.cart_id', + 'c.checkout_step', + 's.session_id', + 's.user_id', + 's.domain', + ], + 'where' => [ + ':c.session_id: = :s.session_id:', + 'c.cart_id' => 5, + 's.session_id' => 'aagdhf', + 's.session_start' => 13, + 'c.create_date' => 19, + 's.domain' => 'example.com', + ':s.user_id: <= 0', + ], + 'lock' => true, + ]; + + // Emulation of the Doctrine platform class + $platform = \Mockery::mock(AbstractPlatform::class)->makePartial(); + + // Return the platform if it is asked for by the connection + $this->connection + ->shouldReceive('getDatabasePlatform') + ->once() + ->andReturn($platform); + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // Return value from fetch + $returnValue = ['id' => '5', 'hash' => 'fhsdkj']; + + // Fetch result set + $statement + ->shouldReceive('fetch') + ->once() + ->with(\Mockery::mustBe(FetchMode::ASSOCIATIVE)) + ->andReturn($returnValue); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('closeCursor') + ->once(); + + $result = $this->db->fetchOne($structuredQuery); + + // Make sure the query has ended with the correct result + $this->assertEquals($returnValue, $result); + + // Query parameters + $query = 'SELECT "c"."cart_id","c"."checkout_step","s"."session_id","s"."user_id","s"."domain" ' . + 'FROM ' . '"carts"' . ' "c",' . '"sessions"' . ' "s" ' . + 'WHERE (c.session_id = s.session_id) ' . + 'AND "c"."cart_id"=? ' . + 'AND "s"."session_id"=? ' . + 'AND "s"."session_start"=? ' . + 'AND "c"."create_date"=? ' . + 'AND "s"."domain"=? ' . + 'AND (s.user_id <= 0) ' . + 'LIMIT 1'; + $vars = [5, 'aagdhf', 13, 19, 'example.com']; + + // Structured query to test + $structuredQuery = [ + 'tables' => [ + 'carts c', + 'sessions s', + ], + 'fields' => [ + 'c.cart_id', + 'c.checkout_step', + 's.session_id', + 's.user_id', + 's.domain', + ], + 'where' => [ + 'c.session_id = s.session_id', + 'c.cart_id' => 5, + 's.session_id' => 'aagdhf', + 's.session_start' => 13, + 'c.create_date' => 19, + 's.domain' => 'example.com', + 's.user_id <= 0', + ], + ]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // Return value from fetch + $returnValue = ['id' => '5', 'hash' => 'fhsdkj']; + + // Fetch result set + $statement + ->shouldReceive('fetch') + ->once() + ->with(\Mockery::mustBe(FetchMode::ASSOCIATIVE)) + ->andReturn($returnValue); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('closeCursor') + ->once(); + + $result = $this->db->fetchOne($structuredQuery); + + // Make sure the query has ended with the correct result + $this->assertEquals($returnValue, $result); + } + + public function testUpdateStructured() + { + $query = 'UPDATE "blobs"."aa_sexy" SET "anyfieldname"=? WHERE "blabla"=?'; + $vars = ['nicevalue', 5]; + + // Emulation of the Doctrine platform class + $platform = \Mockery::mock(AbstractPlatform::class)->makePartial(); + + // Return the platform if it is asked for by the connection + $this->connection + ->shouldReceive('getDatabasePlatform') + ->once() + ->andReturn($platform); + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query . ' LIMIT 13')) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // "RowCount" call on doctrine result statement + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(33); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + $result = $this->db->update([ + 'table' => 'blobs.aa_sexy', + 'changes' => [ + 'anyfieldname' => 'nicevalue', + ], + 'where' => [ + 'blabla' => 5, + ], + 'limit' => 13, + ]); + + $this->assertEquals(33, $result); + } + + public function testUpdateStructuredNULL() + { + $query = 'UPDATE "blobs"."aa_sexy" SET "anyfieldname"=?,"nullentry"=? WHERE "blabla"=?'; + $vars = ['nicevalue', null, 5]; + + // Doctrine statement + $statement = \Mockery::mock(ResultStatement::class); + + // "Prepare" call to doctrine connection + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($query)) + ->andReturn($statement); + + // "Execute" call on doctrine result statement + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe($vars)); + + // "RowCount" call on doctrine result statement + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(33); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + $result = $this->db->update([ + 'table' => 'blobs.aa_sexy', + 'changes' => [ + 'anyfieldname' => 'nicevalue', + 'nullentry' => null, + ], + 'where' => [ + 'blabla' => 5, + ], + ]); + + $this->assertEquals(33, $result); + } + + public function testConvertToSelectSQLStringInvalidOptionFields1() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => 'stringinsteadofarray', + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'setting_id' => 'orders_xml_override', + ':sexy:>?' => '5', + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionFields2() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + new \stdClass(), + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'setting_id' => 'orders_xml_override', + ':sexy:>?' => '5', + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionFields3() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'field' => new \stdClass(), + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'setting_id' => 'orders_xml_override', + ':sexy:>?' => '5', + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionFields4() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + ':field:' => 'field', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'setting_id' => 'orders_xml_override', + ':sexy:>?' => '5', + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionFields5() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 8, + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'setting_id' => 'orders_xml_override', + ':sexy:>?' => '5', + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionTables1() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'where' => [ + 'setting_id' => 'orders_xml_override', + ':sexy:>?' => '5', + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionTables2() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => 'stringisnotallowed', + 'where' => [ + 'setting_id' => 'orders_xml_override', + ':sexy:>?' => '5', + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionTables3() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + new \stdClass(), + ], + 'where' => [ + 'setting_id' => 'orders_xml_override', + ':sexy:>?' => '5', + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionTables4() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 5, + ], + 'where' => [ + 'setting_id' => 'orders_xml_override', + ':sexy:>?' => '5', + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionTablesWithNULL() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + ':blobs.aa_sexy: :a: LEFT JOIN :blobs.dada: :b: ON (:a.setting_id: = ?)' => null, + ], + 'where' => [ + 'setting_id' => 'orders_xml_override', + ':sexy:>?' => '5', + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionWhere1() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => 'stringnotallowed', + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionWhere2() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + new \stdClass(), + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionWhere3() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => new \stdClass(), + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionWhere4() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => [[1, 2]], + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionWhere5() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla', + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionGroup1() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + 'group' => [ + [5], + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionGroup2() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + 'group' => [ + new \stdClass(), + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionOrder1() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + 'order' => [ + new \stdClass(), + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionOrder2() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + 'order' => [ + 5, + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionOrder3() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + 'order' => [ + 'bla' => 'invalidvalue', + ], + 'limit' => 10, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionLimit() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + 'limit' => -2, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionLimitNoInteger() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + 'limit' => '54a', + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionLimitBoolean() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + 'limit' => true, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionOffset() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + 'offset' => -2, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionOffsetNoInteger() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + 'offset' => '45.', + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionLockNoBool() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + 'lock' => 4, + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionFieldAndFields() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'field' => 'boringfield', + 'fields' => [ + 'boringfield', + ], + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionTableAndTables() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'table' => 'blobs.aa_sexy', + 'tables' => [ + 'blobs.aa_sexy', + ], + 'where' => [ + 'blabla' => 5, + ], + ]); + } + + public function testConvertToSelectSQLStringInvalidOptionResourceUsed() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->select([ + 'fields' => [ + 'boringfield', + ], + 'table' => tmpfile(), + 'where' => [ + 'blabla' => 5, + ], + ]); + } + + public function testConvertToUpdateSQLStringInvalidOptionNoChanges() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->update([ + 'changes' => [], + 'table' => 'blobs.aa_sexy', + 'where' => [ + 'blabla' => 5, + ], + ]); + } + + public function testConvertToUpdateSQLStringInvalidOptionBadChange() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->update([ + 'changes' => [ + new \stdClass(), + ], + 'table' => 'blobs.aa_sexy', + 'where' => [ + 'blabla' => 5, + 'dada' => true, + ], + ]); + } + + public function testConvertToUpdateSQLStringInvalidOptionBadExpression() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->update([ + 'changes' => [ + 'no_assignment', + ], + 'table' => 'blobs.aa_sexy', + 'where' => [ + 'blabla' => 5, + ], + ]); + } + + public function testConvertToUpdateSQLStringInvalidOptionBadExpression2() + { + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->update([ + 'changes' => [ + ':no_equal_sign:' => 5, + ], + 'table' => 'blobs.aa_sexy', + 'where' => [ + 'blabla' => 5, + ], + ]); + } +} diff --git a/tests/DoctrineMySQLImplementationTest.php b/tests/DoctrineMySQLImplementationTest.php new file mode 100644 index 0000000..51194d6 --- /dev/null +++ b/tests/DoctrineMySQLImplementationTest.php @@ -0,0 +1,352 @@ +connection = \Mockery::mock(Connection::class); + $this->connection->shouldReceive('quoteIdentifier')->andReturnUsing([$this, 'quoteIdentifier']); + + // MySQL implementation class + $this->db = new DBMySQLImplementation($this->connection); + } + + /** + * Test vanilla upsert without explicit update part + */ + public function testUpsert() + { + // SQL query which should be generated by the implementation + $sql = 'INSERT INTO ' . $this->quoteIdentifier('example.example') . + ' (' . + $this->quoteIdentifier('id') . ',' . + $this->quoteIdentifier('id2') . ',' . + $this->quoteIdentifier('name') . ',' . + $this->quoteIdentifier('bla43') . ',' . + $this->quoteIdentifier('judihui') . + ') VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE ' . + $this->quoteIdentifier('name') . '=?,' . + $this->quoteIdentifier('bla43') . '=?,' . + $this->quoteIdentifier('judihui') . '=?'; + + // Statement and the data values it should receive + $statement = \Mockery::mock(Statement::class); + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe([ + 5, + 6, + 'value', + 'niiiiice', + 5, + 'value', + 'niiiiice', + 5, + ])); + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(1); + + // SQL query should be received by "prepare" + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($sql)) + ->andReturn($statement); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Test the upsert + $result = $this->db->upsert('example.example', [ + 'id' => 5, + 'id2' => 6, + 'name' => 'value', + 'bla43' => 'niiiiice', + 'judihui' => 5, + ], [ + 'id', + 'id2', + ]); + + // Should return 1 + $this->assertSame(1, $result); + } + + /** + * Recreated quoteIdentifier function as a standin for the Doctrine one + * + * @param string $identifier + * @return string + */ + public function quoteIdentifier(string $identifier): string + { + if (strpos($identifier, ".") !== false) { + $parts = array_map( + function ($p) { + return '"' . str_replace('"', '""', $p) . '"'; + }, + explode(".", $identifier) + ); + + return implode(".", $parts); + } + + return '"' . str_replace('"', '""', $identifier) . '"'; + } + + /** + * Test upsert with an explicit update part + */ + public function testUpsertCustomUpdate() + { + // SQL query which should be generated by the implementation + $sql = 'INSERT INTO ' . $this->quoteIdentifier('example.example') . ' (' . + $this->quoteIdentifier('id') . ',' . + $this->quoteIdentifier('id2') . ',' . + $this->quoteIdentifier('name') . ',' . + $this->quoteIdentifier('bla43') . ',' . + $this->quoteIdentifier('judihui') . + ') VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE ' . + $this->quoteIdentifier('name') . '=?,' . + $this->quoteIdentifier('lala45') . '=?,judihui = judihui + 1,' . + $this->quoteIdentifier('evenmore') . '=?,' . + $this->quoteIdentifier('lastone') . '=?'; + + // Statement and the data values it should receive + $statement = \Mockery::mock(Statement::class); + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe([ + 5, + 6, + 'value', + 'niiiiice', + 5, + 'value5', + '534', + 8, + 'laaaast', + ])); + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(2); + + // SQL query should be received by "prepare" + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($sql)) + ->andReturn($statement); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Test the upsert + $result = $this->db->upsert('example.example', [ + 'id' => 5, + 'id2' => 6, + 'name' => 'value', + 'bla43' => 'niiiiice', + 'judihui' => 5, + ], [ + 'id', + 'id2', + ], [ + 'name' => 'value5', + 'lala45' => '534', + 'judihui = judihui + 1', + 'evenmore' => 8, + 'lastone' => 'laaaast', + ]); + + // Should return 2 + $this->assertSame(2, $result); + } + + /** + * Test upsert with an explicit update part and escapeable variables in there + */ + public function testUpsertCustomUpdateWithVars() + { + // SQL query which should be generated by the implementation + $sql = 'INSERT INTO ' . $this->quoteIdentifier('example.example') . ' (' . + $this->quoteIdentifier('id') . ',' . + $this->quoteIdentifier('id2') . ',' . + $this->quoteIdentifier('name') . ',' . + $this->quoteIdentifier('bla43') . ',' . + $this->quoteIdentifier('judihui') . + ') VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE ' . + $this->quoteIdentifier('name') . '=?,' . + $this->quoteIdentifier('lala45') . '=?,' . + $this->quoteIdentifier('judihui') . ' = ' . $this->quoteIdentifier('judihui') . ' + 1,' . + $this->quoteIdentifier('judihui') . ' = ' . $this->quoteIdentifier('judihui') . ' + ?,' . + $this->quoteIdentifier('judihui') . ' = ' . $this->quoteIdentifier('judihui') . ' + ? + ? + ? - ?,' . + $this->quoteIdentifier('evenmore') . '=?,' . $this->quoteIdentifier('lastone') . '=?'; + + // Statement and the data values it should receive + $statement = \Mockery::mock(Statement::class); + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe([ + 5, + 6, + 'value', + 'niiiiice', + 5, + 'value5', + '534', + 13, + 13, + 18, + 67, + 'dada', + 8, + 'laaaast', + ])); + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(2); + + // SQL query should be received by "prepare" + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($sql)) + ->andReturn($statement); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Test the upsert + $result = $this->db->upsert('example.example', [ + 'id' => 5, + 'id2' => 6, + 'name' => 'value', + 'bla43' => 'niiiiice', + 'judihui' => 5, + ], [ + 'id', + 'id2', + ], [ + 'name' => 'value5', + 'lala45' => '534', + ':judihui: = :judihui: + 1', + ':judihui: = :judihui: + ?' => 13, + ':judihui: = :judihui: + ? + ? + ? - ?' => [13, 18, 67, 'dada'], + 'evenmore' => 8, + 'lastone' => 'laaaast', + ]); + + // Should return 2 + $this->assertSame(2, $result); + } + + /** + * Test upsert with an explicit update part and escapeable variables in there + */ + public function testUpsertNoUpdateRows() + { + // SQL query which should be generated by the implementation + $sql = 'INSERT INTO ' . $this->quoteIdentifier('example.example') . ' (' . + $this->quoteIdentifier('id') . ',' . + $this->quoteIdentifier('id2') . + ') VALUES (?,?) ON DUPLICATE KEY UPDATE 1=1'; + + // Statement and the data values it should receive + $statement = \Mockery::mock(Statement::class); + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe([ + 5, + 6, + ])); + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(2); + + // SQL query should be received by "prepare" + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($sql)) + ->andReturn($statement); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Test the upsert + $result = $this->db->upsert('example.example', [ + 'id' => 5, + 'id2' => 6, + ], [ + 'id', + 'id2', + ]); + + // Should return 2 + $this->assertSame(2, $result); + } + + public function testUpsertInvalidOptionNoTableName() + { + // Expect an InvalidOptions exception + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->upsert('', [ + 'dada' => 5, + 'fieldname' => 'rowvalue', + ]); + } + + public function testUpsertInvalidOptionNoRow() + { + // Expect an InvalidOptions exception + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->upsert('table'); + } +} diff --git a/tests/DoctrinePostgreSQLImplementationTest.php b/tests/DoctrinePostgreSQLImplementationTest.php new file mode 100644 index 0000000..a3b794f --- /dev/null +++ b/tests/DoctrinePostgreSQLImplementationTest.php @@ -0,0 +1,356 @@ +connection = \Mockery::mock(Connection::class); + $this->connection->shouldReceive('quoteIdentifier')->andReturnUsing([$this, 'quoteIdentifier']); + + // MySQL implementation class + $this->db = new DBPostgreSQLImplementation($this->connection); + } + + /** + * Test vanilla upsert without explicit update part + */ + public function testUpsert() + { + // SQL query which should be generated by the implementation + $sql = 'INSERT INTO ' . $this->quoteIdentifier('example.example') . + ' (' . + $this->quoteIdentifier('id') . ',' . + $this->quoteIdentifier('id2') . ',' . + $this->quoteIdentifier('name') . ',' . + $this->quoteIdentifier('bla43') . ',' . + $this->quoteIdentifier('judihui') . + ') VALUES (?,?,?,?,?) ' . + 'ON CONFLICT (' . $this->quoteIdentifier('id') . ',' . $this->quoteIdentifier('id2') . ') DO UPDATE ' . + $this->quoteIdentifier('name') . '=?,' . + $this->quoteIdentifier('bla43') . '=?,' . + $this->quoteIdentifier('judihui') . '=?'; + + // Statement and the data values it should receive + $statement = \Mockery::mock(Statement::class); + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe([ + 5, + 6, + 'value', + 'niiiiice', + 5, + 'value', + 'niiiiice', + 5, + ])); + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(1); + + // SQL query should be received by "prepare" + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($sql)) + ->andReturn($statement); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Test the upsert + $result = $this->db->upsert('example.example', [ + 'id' => 5, + 'id2' => 6, + 'name' => 'value', + 'bla43' => 'niiiiice', + 'judihui' => 5, + ], [ + 'id', + 'id2', + ]); + + // Should return 1 + $this->assertSame(1, $result); + } + + /** + * Recreated quoteIdentifier function as a standin for the Doctrine one + * + * @param string $identifier + * @return string + */ + public function quoteIdentifier(string $identifier): string + { + if (strpos($identifier, ".") !== false) { + $parts = array_map( + function ($p) { + return '"' . str_replace('"', '""', $p) . '"'; + }, + explode(".", $identifier) + ); + + return implode(".", $parts); + } + + return '"' . str_replace('"', '""', $identifier) . '"'; + } + + /** + * Test upsert with an explicit update part + */ + public function testUpsertCustomUpdate() + { + // SQL query which should be generated by the implementation + $sql = 'INSERT INTO ' . $this->quoteIdentifier('example.example') . ' (' . + $this->quoteIdentifier('id') . ',' . + $this->quoteIdentifier('id2') . ',' . + $this->quoteIdentifier('name') . ',' . + $this->quoteIdentifier('bla43') . ',' . + $this->quoteIdentifier('judihui') . + ') VALUES (?,?,?,?,?) ' . + 'ON CONFLICT (' . $this->quoteIdentifier('id') . ',' . $this->quoteIdentifier('id2') . ') DO UPDATE ' . + $this->quoteIdentifier('name') . '=?,' . + $this->quoteIdentifier('lala45') . '=?,judihui = judihui + 1,' . + $this->quoteIdentifier('evenmore') . '=?,' . + $this->quoteIdentifier('lastone') . '=?'; + + // Statement and the data values it should receive + $statement = \Mockery::mock(Statement::class); + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe([ + 5, + 6, + 'value', + 'niiiiice', + 5, + 'value5', + '534', + 8, + 'laaaast', + ])); + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(2); + + // SQL query should be received by "prepare" + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($sql)) + ->andReturn($statement); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Test the upsert + $result = $this->db->upsert('example.example', [ + 'id' => 5, + 'id2' => 6, + 'name' => 'value', + 'bla43' => 'niiiiice', + 'judihui' => 5, + ], [ + 'id', + 'id2', + ], [ + 'name' => 'value5', + 'lala45' => '534', + 'judihui = judihui + 1', + 'evenmore' => 8, + 'lastone' => 'laaaast', + ]); + + // Should return 2 + $this->assertSame(2, $result); + } + + /** + * Test upsert with an explicit update part and escapeable variables in there + */ + public function testUpsertCustomUpdateWithVars() + { + // SQL query which should be generated by the implementation + $sql = 'INSERT INTO ' . $this->quoteIdentifier('example.example') . ' (' . + $this->quoteIdentifier('id') . ',' . + $this->quoteIdentifier('id2') . ',' . + $this->quoteIdentifier('name') . ',' . + $this->quoteIdentifier('bla43') . ',' . + $this->quoteIdentifier('judihui') . + ') VALUES (?,?,?,?,?) ' . + 'ON CONFLICT (' . $this->quoteIdentifier('id') . ',' . $this->quoteIdentifier('id2') . ') DO UPDATE ' . + $this->quoteIdentifier('name') . '=?,' . + $this->quoteIdentifier('lala45') . '=?,' . + $this->quoteIdentifier('judihui') . ' = ' . $this->quoteIdentifier('judihui') . ' + 1,' . + $this->quoteIdentifier('judihui') . ' = ' . $this->quoteIdentifier('judihui') . ' + ?,' . + $this->quoteIdentifier('judihui') . ' = ' . $this->quoteIdentifier('judihui') . ' + ? + ? + ? - ?,' . + $this->quoteIdentifier('evenmore') . '=?,' . $this->quoteIdentifier('lastone') . '=?'; + + // Statement and the data values it should receive + $statement = \Mockery::mock(Statement::class); + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe([ + 5, + 6, + 'value', + 'niiiiice', + 5, + 'value5', + '534', + 13, + 13, + 18, + 67, + 'dada', + 8, + 'laaaast', + ])); + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(2); + + // SQL query should be received by "prepare" + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($sql)) + ->andReturn($statement); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Test the upsert + $result = $this->db->upsert('example.example', [ + 'id' => 5, + 'id2' => 6, + 'name' => 'value', + 'bla43' => 'niiiiice', + 'judihui' => 5, + ], [ + 'id', + 'id2', + ], [ + 'name' => 'value5', + 'lala45' => '534', + ':judihui: = :judihui: + 1', + ':judihui: = :judihui: + ?' => 13, + ':judihui: = :judihui: + ? + ? + ? - ?' => [13, 18, 67, 'dada'], + 'evenmore' => 8, + 'lastone' => 'laaaast', + ]); + + // Should return 2 + $this->assertSame(2, $result); + } + + /** + * Test upsert with an explicit update part and escapeable variables in there + */ + public function testUpsertNoUpdateRows() + { + // SQL query which should be generated by the implementation + $sql = 'INSERT INTO ' . $this->quoteIdentifier('example.example') . ' (' . + $this->quoteIdentifier('id') . ',' . + $this->quoteIdentifier('id2') . + ') VALUES (?,?) ' . + 'ON CONFLICT (' . $this->quoteIdentifier('id') . ',' . $this->quoteIdentifier('id2') . ') DO UPDATE 1=1'; + + // Statement and the data values it should receive + $statement = \Mockery::mock(Statement::class); + $statement + ->shouldReceive('execute') + ->once() + ->with(\Mockery::mustBe([ + 5, + 6, + ])); + $statement + ->shouldReceive('rowCount') + ->once() + ->andReturn(2); + + // SQL query should be received by "prepare" + $this->connection + ->shouldReceive('prepare') + ->once() + ->with(\Mockery::mustBe($sql)) + ->andReturn($statement); + + // Close result set + $statement + ->shouldReceive('closeCursor') + ->once(); + + // Test the upsert + $result = $this->db->upsert('example.example', [ + 'id' => 5, + 'id2' => 6, + ], [ + 'id', + 'id2', + ]); + + // Should return 2 + $this->assertSame(2, $result); + } + + public function testUpsertInvalidOptionNoTableName() + { + // Expect an InvalidOptions exception + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->upsert('', [ + 'dada' => 5, + 'fieldname' => 'rowvalue', + ]); + } + + public function testUpsertInvalidOptionNoRow() + { + // Expect an InvalidOptions exception + $this->expectException(DBInvalidOptionException::class); + + // Try it with the invalid option + $this->db->upsert('table'); + } +}