From ffd60e899d5a742a6628baa49547e018cdced83b Mon Sep 17 00:00:00 2001 From: Andreas Leathley Date: Tue, 23 Apr 2019 19:58:25 +0200 Subject: [PATCH] First official release Some parts are still missing: - Complete test coverage (although it is quite far along) - Sensible documentation - There might still be some errors in existing class documentation, as things were hurried a bit to finally have an official release That is why this is a 0.2 release, meaning we are not sure yet if everything will stay exactly the same. --- .gitattributes | 6 + .gitignore | 6 + LICENSE | 21 + README.md | 6 + captainhook.json | 60 + composer.json | 62 + phpunit.xml | 21 + squirrel_repositories_generate | 49 + src/Action/ActionInterface.php | 10 + src/Action/CountEntries.php | 51 + src/Action/DeleteEntries.php | 50 + src/Action/InsertEntry.php | 51 + src/Action/InsertOrUpdateEntry.php | 92 + src/Action/MultiCountEntries.php | 83 + src/Action/MultiSelectEntries.php | 203 ++ src/Action/MultiSelectEntriesFreeform.php | 130 ++ src/Action/MultiUpdateEntries.php | 128 ++ src/Action/MultiUpdateEntriesFreeform.php | 111 + src/Action/SelectEntries.php | 147 ++ src/Action/UpdateEntries.php | 101 + src/Annotation/Entity.php | 26 + src/Annotation/EntityProcessor.php | 85 + src/Annotation/Field.php | 34 + src/Generate/FindClassesWithAnnotation.php | 103 + src/Generate/RepositoriesGenerateCommand.php | 389 ++++ src/MultiRepositoryBuilderReadOnly.php | 44 + ...ultiRepositoryBuilderReadOnlyInterface.php | 25 + src/MultiRepositoryBuilderWriteable.php | 37 + ...ltiRepositoryBuilderWriteableInterface.php | 19 + src/MultiRepositoryReadOnly.php | 1079 +++++++++ src/MultiRepositoryReadOnlyInterface.php | 59 + src/MultiRepositoryWriteable.php | 193 ++ src/MultiRepositoryWriteableInterface.php | 34 + src/RepositoryBuilderReadOnlyInterface.php | 17 + src/RepositoryBuilderWriteableInterface.php | 19 + src/RepositoryConfig.php | 128 ++ src/RepositoryConfigInterface.php | 46 + src/RepositoryReadOnly.php | 675 ++++++ src/RepositoryReadOnlyInterface.php | 66 + src/RepositoryWriteable.php | 284 +++ src/RepositoryWriteableInterface.php | 51 + src/Transaction.php | 115 + src/TransactionInterface.php | 19 + tests/MultiRepositoryBuilderReadOnlyTest.php | 42 + tests/MultiRepositoryBuilderWriteableTest.php | 32 + tests/MultiRepositoryReadOnlyTest.php | 1969 +++++++++++++++++ tests/MultiRepositoryWriteableTest.php | 712 ++++++ tests/RepositoryActions/CountEntriesTest.php | 62 + tests/RepositoryActions/DeleteEntriesTest.php | 55 + tests/RepositoryActions/InsertEntryTest.php | 77 + .../InsertOrUpdateEntryTest.php | 168 ++ tests/RepositoryActions/SelectEntriesTest.php | 257 +++ tests/RepositoryActions/UpdateEntriesTest.php | 181 ++ tests/RepositoryTest.php | 1779 +++++++++++++++ tests/TestClasses/ObjData.php | 49 + ...dOnlyDifferentRepositoryVariableWithin.php | 40 + .../TicketRepositoryBuilderReadOnly.php | 31 + .../TicketRepositoryBuilderWriteable.php | 45 + ...orrectNameButInvalidDatabaseConnection.php | 61 + ...fferentRepositoryBuilderVariableWithin.php | 31 + tests/TransactionTest.php | 255 +++ 61 files changed, 10781 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 captainhook.json create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100755 squirrel_repositories_generate create mode 100644 src/Action/ActionInterface.php create mode 100644 src/Action/CountEntries.php create mode 100644 src/Action/DeleteEntries.php create mode 100644 src/Action/InsertEntry.php create mode 100644 src/Action/InsertOrUpdateEntry.php create mode 100644 src/Action/MultiCountEntries.php create mode 100644 src/Action/MultiSelectEntries.php create mode 100644 src/Action/MultiSelectEntriesFreeform.php create mode 100644 src/Action/MultiUpdateEntries.php create mode 100644 src/Action/MultiUpdateEntriesFreeform.php create mode 100644 src/Action/SelectEntries.php create mode 100644 src/Action/UpdateEntries.php create mode 100644 src/Annotation/Entity.php create mode 100644 src/Annotation/EntityProcessor.php create mode 100644 src/Annotation/Field.php create mode 100644 src/Generate/FindClassesWithAnnotation.php create mode 100644 src/Generate/RepositoriesGenerateCommand.php create mode 100644 src/MultiRepositoryBuilderReadOnly.php create mode 100644 src/MultiRepositoryBuilderReadOnlyInterface.php create mode 100644 src/MultiRepositoryBuilderWriteable.php create mode 100644 src/MultiRepositoryBuilderWriteableInterface.php create mode 100644 src/MultiRepositoryReadOnly.php create mode 100644 src/MultiRepositoryReadOnlyInterface.php create mode 100644 src/MultiRepositoryWriteable.php create mode 100644 src/MultiRepositoryWriteableInterface.php create mode 100644 src/RepositoryBuilderReadOnlyInterface.php create mode 100644 src/RepositoryBuilderWriteableInterface.php create mode 100644 src/RepositoryConfig.php create mode 100644 src/RepositoryConfigInterface.php create mode 100644 src/RepositoryReadOnly.php create mode 100644 src/RepositoryReadOnlyInterface.php create mode 100644 src/RepositoryWriteable.php create mode 100644 src/RepositoryWriteableInterface.php create mode 100644 src/Transaction.php create mode 100644 src/TransactionInterface.php create mode 100644 tests/MultiRepositoryBuilderReadOnlyTest.php create mode 100644 tests/MultiRepositoryBuilderWriteableTest.php create mode 100644 tests/MultiRepositoryReadOnlyTest.php create mode 100644 tests/MultiRepositoryWriteableTest.php create mode 100644 tests/RepositoryActions/CountEntriesTest.php create mode 100644 tests/RepositoryActions/DeleteEntriesTest.php create mode 100644 tests/RepositoryActions/InsertEntryTest.php create mode 100644 tests/RepositoryActions/InsertOrUpdateEntryTest.php create mode 100644 tests/RepositoryActions/SelectEntriesTest.php create mode 100644 tests/RepositoryActions/UpdateEntriesTest.php create mode 100644 tests/RepositoryTest.php create mode 100644 tests/TestClasses/ObjData.php create mode 100644 tests/TestClasses/TicketMessageRepositoryReadOnlyDifferentRepositoryVariableWithin.php create mode 100644 tests/TestClasses/TicketRepositoryBuilderReadOnly.php create mode 100644 tests/TestClasses/TicketRepositoryBuilderWriteable.php create mode 100644 tests/TestClasses/TicketRepositoryReadOnlyCorrectNameButInvalidDatabaseConnection.php create mode 100644 tests/TestClasses/TicketRepositoryReadOnlyDifferentRepositoryBuilderVariableWithin.php create mode 100644 tests/TransactionTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..97b8ec6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +/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..2652c20 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +Squirrel Entities Component +=========================== + +Simple, safe and flexible implementation of handling SQL entities and repositories as well as multi-table SQL queries while staying lightweight and easy to understand and use. + +This package is still not 100% finished - some tests are missing (although the most important parts have test coverage) and the documentation will be a challenge. For now it is released so it can be used as-is and improved over time. \ 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..88c2dda --- /dev/null +++ b/composer.json @@ -0,0 +1,62 @@ +{ + "name": "squirrelphp/entities", + "type": "library", + "description": "Simple, safe and flexible implementation of handling SQL entities and repositories as well as multi-table SQL queries while staying lightweight and straightforward.", + "keywords": [ + "php", + "mysql", + "pgsql", + "sqlite", + "database", + "entities", + "repositories" + ], + "homepage": "https://github.com/squirrelphp/entities", + "license": "MIT", + "authors": [ + { + "name": "Andreas Leathley", + "email": "andreas.leathley@panaxis.ch" + } + ], + "require": { + "php": "^7.2", + "symfony/console": "^4.0", + "symfony/finder": "^4.0", + "doctrine/annotations": "^1.4", + "squirrelphp/queries": "^0.5.4" + }, + "require-dev": { + "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 squirrel/queries - automatic assembling of decorated connections", + "squirrelphp/entities-bundle": "Automatic integration of squirrel/entities in Symfony" + }, + "config": { + "sort-packages": true + }, + "bin": [ + "squirrel_repositories_generate" + ], + "autoload": { + "psr-4": { + "Squirrel\\Entities\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Squirrel\\Entities\\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/squirrel_repositories_generate b/squirrel_repositories_generate new file mode 100755 index 0000000..664cb1e --- /dev/null +++ b/squirrel_repositories_generate @@ -0,0 +1,49 @@ +#!/usr/bin/env php +addOption(new InputOption( + 'source-dir', + null, + InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, + 'Source directories (relative to current directory) where entities will be searched recursively' +)); + +$input = new ArgvInput(null, $inputDefinition); +$srcDirectories = $input->getOption('source-dir'); + +// Execute command to generate repositories +$cmd = new \Squirrel\Entities\Generate\RepositoriesGenerateCommand($srcDirectories); +$log = $cmd(); + +// Add summary of processed entities +$log[] = "\n" . count($log) . ' entities found for which repositories were generated.' . "\n"; + +// Show log +echo implode("\n", $log); \ No newline at end of file diff --git a/src/Action/ActionInterface.php b/src/Action/ActionInterface.php new file mode 100644 index 0000000..ada6a5e --- /dev/null +++ b/src/Action/ActionInterface.php @@ -0,0 +1,10 @@ +repository = $repository; + } + + public function where(array $whereClauses): self + { + $this->where = $whereClauses; + return $this; + } + + public function blocking(bool $active = true): self + { + $this->blocking = $active; + return $this; + } + + public function getNumber(): int + { + return $this->repository->count([ + 'where' => $this->where, + 'lock' => $this->blocking, + ]); + } +} diff --git a/src/Action/DeleteEntries.php b/src/Action/DeleteEntries.php new file mode 100644 index 0000000..c575f15 --- /dev/null +++ b/src/Action/DeleteEntries.php @@ -0,0 +1,50 @@ +repository = $repository; + } + + public function where(array $whereClauses): self + { + $this->where = $whereClauses; + return $this; + } + + /** + * Write changes to database + */ + public function write(): void + { + $this->repository->delete($this->where); + } + + /** + * Write changes to database and return affected entries number + * + * @return int Number of affected entries in database + */ + public function writeAndReturnAffectedNumber(): int + { + return $this->repository->delete($this->where); + } +} diff --git a/src/Action/InsertEntry.php b/src/Action/InsertEntry.php new file mode 100644 index 0000000..70c92fc --- /dev/null +++ b/src/Action/InsertEntry.php @@ -0,0 +1,51 @@ +repository = $repository; + } + + public function set(array $values): self + { + $this->values = $values; + return $this; + } + + /** + * Write changes to database + */ + public function write(): void + { + $this->repository->insert($this->values); + } + + /** + * Write changes to database and return new insert ID + * + * @return string Return new autoincrement insert ID from database + */ + public function writeAndReturnNewId(): string + { + // ?? clause only included to make it explicit for linters that we always return a string + return $this->repository->insert($this->values, true) ?? ''; + } +} diff --git a/src/Action/InsertOrUpdateEntry.php b/src/Action/InsertOrUpdateEntry.php new file mode 100644 index 0000000..81f32fb --- /dev/null +++ b/src/Action/InsertOrUpdateEntry.php @@ -0,0 +1,92 @@ +repository = $repository; + } + + public function set(array $values): self + { + $this->values = $values; + return $this; + } + + /** + * @param array|string $indexFields + * @return InsertOrUpdateEntry + */ + public function index($indexFields): self + { + if (\is_string($indexFields)) { + $indexFields = [$indexFields]; + } + + $this->index = $indexFields; + return $this; + } + + /** + * @param array|string $values + * @return InsertOrUpdateEntry + */ + public function setOnUpdate($values): self + { + if (\is_string($values)) { + $values = [$values]; + } + + $this->valuesOnUpdate = $values; + return $this; + } + + /** + * Write changes to database + */ + public function write(): void + { + $this->repository->insertOrUpdate($this->values, $this->index, $this->valuesOnUpdate); + } + + /** + * Write changes to database and return the operation that happened in the database: + * + * "insert": Entry was inserted + * "update": Existing entry was updated + * "": Nothing changed, existing entry was already up-to-date + * + * @return string Either returns "insert", "update" or "" to indicate what operation happened + */ + public function writeAndReturnWhatHappened(): string + { + return $this->repository->insertOrUpdate($this->values, $this->index, $this->valuesOnUpdate); + } +} diff --git a/src/Action/MultiCountEntries.php b/src/Action/MultiCountEntries.php new file mode 100644 index 0000000..1744416 --- /dev/null +++ b/src/Action/MultiCountEntries.php @@ -0,0 +1,83 @@ +queryHandler = $queryHandler; + } + + public function inRepositories(array $repositories) + { + $this->repositories = $repositories; + } + + public function connectedBy(array $repositoryConnections) + { + $this->connections = $repositoryConnections; + } + + public function where(array $whereClauses): self + { + $this->where = $whereClauses; + return $this; + } + + public function blocking(bool $active = true): self + { + $this->blocking = $active; + return $this; + } + + /** + * Execute SELECT query and return number of entries + */ + public function getNumber(): int + { + $results = $this->queryHandler->select([ + 'fields' => [ + 'num' => 'COUNT(*)', + ], + 'repositories' => $this->repositories, + 'tables' => $this->connections, + 'where' => $this->where, + 'flattenFields' => true, + 'lock' => $this->blocking, + ]); + + return $results[0] ?? 0; + } +} diff --git a/src/Action/MultiSelectEntries.php b/src/Action/MultiSelectEntries.php new file mode 100644 index 0000000..86ca074 --- /dev/null +++ b/src/Action/MultiSelectEntries.php @@ -0,0 +1,203 @@ +queryHandler = $queryHandler; + } + + public function field(string $getThisField): self + { + $this->fields = [$getThisField]; + return $this; + } + + public function fields(array $getTheseFields): self + { + $this->fields = $getTheseFields; + return $this; + } + + public function inRepositories(array $repositories): self + { + $this->repositories = $repositories; + return $this; + } + + public function joinTables(array $repositoryConnections): self + { + $this->connections = $repositoryConnections; + return $this; + } + + public function where(array $whereClauses): self + { + $this->where = $whereClauses; + return $this; + } + + /** + * @param array|string $orderByClauses + * @return MultiSelectEntries + */ + public function orderBy($orderByClauses): self + { + if (\is_string($orderByClauses)) { + $orderByClauses = [$orderByClauses]; + } + + $this->orderBy = $orderByClauses; + return $this; + } + + /** + * @param array|string $groupByClauses + * @return MultiSelectEntries + */ + public function groupBy($groupByClauses): self + { + if (\is_string($groupByClauses)) { + $groupByClauses = [$groupByClauses]; + } + + $this->groupBy = $groupByClauses; + return $this; + } + + public function startAt(int $startAtNumber): self + { + $this->startAt = $startAtNumber; + return $this; + } + + public function limitTo(int $numberOfEntries): self + { + $this->limitTo = $numberOfEntries; + return $this; + } + + public function blocking(bool $active = true): self + { + $this->blocking = $active; + return $this; + } + + /** + * Execute SELECT query and return a list of entries as arrays that matched it + */ + public function getEntries(): array + { + return $this->queryHandler->select([ + 'fields' => $this->fields, + 'repositories' => $this->repositories, + 'tables' => $this->connections, + 'where' => $this->where, + 'order' => $this->orderBy, + 'group' => $this->groupBy, + 'limit' => $this->limitTo, + 'offset' => $this->startAt, + 'lock' => $this->blocking, + ]); + } + + /** + * Execute SELECT query and return exactly one entry, if one was found at all + * + * @return array|null + */ + public function getOneEntry(): ?array + { + $results = $this->queryHandler->select([ + 'fields' => $this->fields, + 'repositories' => $this->repositories, + 'tables' => $this->connections, + 'where' => $this->where, + 'order' => $this->orderBy, + 'group' => $this->groupBy, + 'limit' => 1, + 'offset' => $this->startAt, + 'lock' => $this->blocking, + ]); + + return \array_pop($results); + } + + /** + * Execute SELECT query and return the fields as a list of values + * + * @return string[]|int[]|float[]|bool[]|null[] + */ + public function getFlattenedFields(): array + { + return $this->queryHandler->selectFlattenedFields([ + 'fields' => $this->fields, + 'repositories' => $this->repositories, + 'tables' => $this->connections, + 'where' => $this->where, + 'order' => $this->orderBy, + 'group' => $this->groupBy, + 'limit' => $this->limitTo, + 'offset' => $this->startAt, + 'lock' => $this->blocking, + ]); + } +} diff --git a/src/Action/MultiSelectEntriesFreeform.php b/src/Action/MultiSelectEntriesFreeform.php new file mode 100644 index 0000000..6f446b9 --- /dev/null +++ b/src/Action/MultiSelectEntriesFreeform.php @@ -0,0 +1,130 @@ +queryHandler = $queryHandler; + } + + public function field(string $getThisField): self + { + $this->fields = [$getThisField]; + return $this; + } + + public function fields(array $getTheseFields): self + { + $this->fields = $getTheseFields; + return $this; + } + + public function inRepositories(array $repositories) + { + $this->repositories = $repositories; + } + + public function queryAfterFROM(string $query): self + { + $this->query = $query; + return $this; + } + + public function withParameters(array $queryParameters = []): self + { + $this->parameters = $queryParameters; + return $this; + } + + public function freeformQueriesAreBadPractice(string $confirmWithOK): self + { + if ($confirmWithOK === 'OK') { + $this->confirmBadPractice = true; + } + return $this; + } + + /** + * Execute SELECT query and return a list of entries as arrays that matched it + */ + public function getEntries(): array + { + if ($this->confirmBadPractice !== true) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [ActionInterface::class], + 'No confirmation that freeform queries are bad practice' + ); + } + + return $this->queryHandler->select([ + 'fields' => $this->fields, + 'repositories' => $this->repositories, + 'query' => $this->query, + 'parameters' => $this->parameters, + ]); + } + + /** + * Execute SELECT query and return the fields as a list of values + * + * @return string[]|int[]|float[]|bool[]|null[] + */ + public function getFlattenedFields(): array + { + if ($this->confirmBadPractice !== true) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [ActionInterface::class], + 'No confirmation that freeform queries are bad practice' + ); + } + + return $this->queryHandler->selectFlattenedFields([ + 'fields' => $this->fields, + 'repositories' => $this->repositories, + 'query' => $this->query, + 'parameters' => $this->parameters, + ]); + } +} diff --git a/src/Action/MultiUpdateEntries.php b/src/Action/MultiUpdateEntries.php new file mode 100644 index 0000000..3d4457e --- /dev/null +++ b/src/Action/MultiUpdateEntries.php @@ -0,0 +1,128 @@ +queryHandler = $queryHandler; + } + + public function inRepositories(array $repositories): self + { + $this->repositories = $repositories; + return $this; + } + + public function joinTables(array $repositoryConnections): self + { + $this->connections = $repositoryConnections; + return $this; + } + + public function set(array $changes): self + { + $this->changes = $changes; + return $this; + } + + public function where(array $whereClauses): self + { + $this->where = $whereClauses; + return $this; + } + + /** + * @param array|string $orderByClauses + * @return MultiUpdateEntries + */ + public function orderBy($orderByClauses): self + { + if (\is_string($orderByClauses)) { + $orderByClauses = [$orderByClauses]; + } + + $this->orderBy = $orderByClauses; + return $this; + } + + public function limitTo(int $numberOfEntries): self + { + $this->limitTo = $numberOfEntries; + return $this; + } + + /** + * Write changes to database + */ + public function write(): void + { + $this->queryHandler->update([ + 'repositories' => $this->repositories, + 'tables' => $this->connections, + 'changes' => $this->changes, + 'where' => $this->where, + 'order' => $this->orderBy, + 'limit' => $this->limitTo, + ]); + } + + /** + * Write changes to database and return affected entries number + * + * @return int Number of affected entries in database + */ + public function writeAndReturnAffectedNumber(): int + { + return $this->queryHandler->update([ + 'repositories' => $this->repositories, + 'tables' => $this->connections, + 'changes' => $this->changes, + 'where' => $this->where, + 'order' => $this->orderBy, + 'limit' => $this->limitTo, + ]); + } +} diff --git a/src/Action/MultiUpdateEntriesFreeform.php b/src/Action/MultiUpdateEntriesFreeform.php new file mode 100644 index 0000000..fe40a4b --- /dev/null +++ b/src/Action/MultiUpdateEntriesFreeform.php @@ -0,0 +1,111 @@ +queryHandler = $queryHandler; + } + + public function inRepositories(array $repositories) + { + $this->repositories = $repositories; + } + + public function query(string $query): self + { + $this->query = $query; + return $this; + } + + public function withParameters(array $queryParameters = []): self + { + $this->parameters = $queryParameters; + return $this; + } + + public function freeformQueriesAreBadPractice(string $confirmWithOK): self + { + if ($confirmWithOK === 'OK') { + $this->confirmBadPractice = true; + } + return $this; + } + + /** + * Write changes to database + */ + public function write(): void + { + if ($this->confirmBadPractice !== true) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [ActionInterface::class], + 'No confirmation that freeform queries are bad practice' + ); + } + + $this->queryHandler->update([ + 'repositories' => $this->repositories, + 'query' => $this->query, + 'parameters' => $this->parameters, + ]); + } + + /** + * Write changes to database and return affected entries number + * + * @return int Number of affected entries in database + */ + public function writeAndReturnAffectedNumber(): int + { + if ($this->confirmBadPractice !== true) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [ActionInterface::class], + 'No confirmation that freeform queries are bad practice' + ); + } + + return $this->queryHandler->update([ + 'repositories' => $this->repositories, + 'query' => $this->query, + 'parameters' => $this->parameters, + ]); + } +} diff --git a/src/Action/SelectEntries.php b/src/Action/SelectEntries.php new file mode 100644 index 0000000..58e8c6e --- /dev/null +++ b/src/Action/SelectEntries.php @@ -0,0 +1,147 @@ +repository = $repository; + } + + public function field(string $onlyGetThisField): self + { + $this->fields = [$onlyGetThisField]; + return $this; + } + + public function fields(array $onlyGetTheseFields): self + { + $this->fields = $onlyGetTheseFields; + return $this; + } + + public function where(array $whereClauses): self + { + $this->where = $whereClauses; + return $this; + } + + /** + * @param array|string $orderByClauses + * @return SelectEntries + */ + public function orderBy($orderByClauses): self + { + if (\is_string($orderByClauses)) { + $orderByClauses = [$orderByClauses]; + } + + $this->orderBy = $orderByClauses; + return $this; + } + + public function startAt(int $startAtNumber): self + { + $this->startAt = $startAtNumber; + return $this; + } + + public function limitTo(int $numberOfEntries): self + { + $this->limitTo = $numberOfEntries; + return $this; + } + + public function blocking(bool $active = true): self + { + $this->blocking = $active; + return $this; + } + + /** + * Execute SELECT query and return a list of objects that matched it + */ + public function getEntries(): array + { + return $this->repository->select([ + 'where' => $this->where, + 'order' => $this->orderBy, + 'fields' => $this->fields, + 'limit' => $this->limitTo, + 'offset' => $this->startAt, + 'lock' => $this->blocking, + ]); + } + + /** + * Execute SELECT query and return exactly one entry, if one was found at all + */ + public function getOneEntry() + { + return $this->repository->selectOne([ + 'where' => $this->where, + 'order' => $this->orderBy, + 'fields' => $this->fields, + 'offset' => $this->startAt, + 'lock' => $this->blocking, + ]); + } + + /** + * Execute SELECT query and return the fields as a list of values - no objects + * + * @return string[]|int[]|bool[]|null[] + */ + public function getFlattenedFields(): array + { + return $this->repository->selectFlattenedFields([ + 'where' => $this->where, + 'order' => $this->orderBy, + 'fields' => $this->fields, + 'limit' => $this->limitTo, + 'offset' => $this->startAt, + 'lock' => $this->blocking, + ]); + } +} diff --git a/src/Action/UpdateEntries.php b/src/Action/UpdateEntries.php new file mode 100644 index 0000000..4c77bf4 --- /dev/null +++ b/src/Action/UpdateEntries.php @@ -0,0 +1,101 @@ +repository = $repository; + } + + public function set(array $changes): self + { + $this->changes = $changes; + return $this; + } + + public function where(array $whereClauses): self + { + $this->where = $whereClauses; + return $this; + } + + /** + * @param array|string $orderByClauses + * @return UpdateEntries + */ + public function orderBy($orderByClauses): self + { + if (\is_string($orderByClauses)) { + $orderByClauses = [$orderByClauses]; + } + + $this->orderBy = $orderByClauses; + return $this; + } + + public function limitTo(int $numberOfEntries): self + { + $this->limitTo = $numberOfEntries; + return $this; + } + + /** + * Write changes to database + */ + public function write(): void + { + $this->repository->update([ + 'changes' => $this->changes, + 'where' => $this->where, + 'order' => $this->orderBy, + 'limit' => $this->limitTo, + ]); + } + + /** + * Write changes to database and return affected entries number + * + * @return int Number of affected entries in database + */ + public function writeAndReturnAffectedNumber(): int + { + return $this->repository->update([ + 'changes' => $this->changes, + 'where' => $this->where, + 'order' => $this->orderBy, + 'limit' => $this->limitTo, + ]); + } +} diff --git a/src/Annotation/Entity.php b/src/Annotation/Entity.php new file mode 100644 index 0000000..106abc7 --- /dev/null +++ b/src/Annotation/Entity.php @@ -0,0 +1,26 @@ +annotationReader = $annotationReader; + } + + /** + * Processes a class according to its Convert annotation + * + * @param object|string $class + * @return null|RepositoryConfig + */ + public function process($class) + { + // Get class reflection data + $annotationClass = new \ReflectionClass($class); + + // Get entity annotation for class + $entity = $this->annotationReader->getClassAnnotation($annotationClass, Entity::class); + + // This class was annotated as Entity + if ($entity instanceof Entity) { + // Configuration options which need to be populated + $tableToObjectFields = []; + $objectToTableFields = []; + $objectTypes = []; + $objectTypesNullable = []; + + // Go through all public values of the class + foreach ($annotationClass->getProperties() as $property) { + // Get annotations for a propery + $annotationProperty = new \ReflectionProperty($class, $property->getName()); + + // Find Field annotation on the property + $field = $this->annotationReader->getPropertyAnnotation( + $annotationProperty, + Field::class + ); + + // A Field annotation was found + if ($field instanceof Field) { + $tableToObjectFields[$field->name] = $property->getName(); + $objectToTableFields[$property->getName()] = $field->name; + $objectTypes[$property->getName()] = $field->type; + $objectTypesNullable[$property->getName()] = $field->nullable; + } + } + + // Create new config for a repository + return new RepositoryConfig( + $entity->connection, + $entity->name, + $tableToObjectFields, + $objectToTableFields, + $annotationClass->getName(), + $objectTypes, + $objectTypesNullable + ); + } + + // No entity found, so no configuration could be generated + return null; + } +} diff --git a/src/Annotation/Field.php b/src/Annotation/Field.php new file mode 100644 index 0000000..e18f1bd --- /dev/null +++ b/src/Annotation/Field.php @@ -0,0 +1,34 @@ + $token) { + // "use" name started, so collect all parts until we reach the end of the name + if ($useImportStarted === true) { + // String and namespace separator are all part of the class name + if (\in_array($token[0], [T_STRING, T_NS_SEPARATOR])) { + $importClassName .= $token[1]; + } elseif ($token[0] === T_WHITESPACE) { // Ignore whitespace, can be before or after the class name + } else { // Every other token indicates that we have reached the end of the name + $namespaceStarted = false; + + // We have found the SQLMapper annotation - so there can be entities in this file + if ($importClassName === 'Squirrel\\Entities\\Annotation') { + $annotationUseFound = true; + } + } + } + + // "namespace" name started, so collect all parts until we reach the end of the name + if ($namespaceStarted === true) { + // String and namespace separator are all part of the namespace + if (\in_array($token[0], [T_STRING, T_NS_SEPARATOR])) { + $namespace .= $token[1]; + } elseif ($token[0] === T_WHITESPACE) { // Ignore whitespace, can be before or after the namespace + } else { // Every other token indicates that we have reached the end of the name + $namespaceStarted = false; + } + } + + // "class" name started, so collect all parts until we reach the end of the name + if ($classNameStarted === true) { + // String and namespace separator are all part of the class name + if (\in_array($token[0], [T_STRING, T_NS_SEPARATOR])) { + $className .= $token[1]; + } elseif ($token[0] === T_WHITESPACE) { // Ignore whitespace, can be before or after the class name + } else { // Every other token indicates that we have reached the end of the name + $classNameStarted = false; + + // SQLMapper annotation found beforehand and we have a classname - add it to list + if (\strlen($className) > 0 && $annotationUseFound === true) { + $classes[] = [$namespace, $className]; + } + + // Reset class name to maybe find another one + $className = ''; + } + } + + // "use" token - everything coming after this has to be checked for the + // SQLMapper annotation class + if ($token[0] === T_USE) { + $useImportStarted = true; + $importClassName = ''; + } + + // "namespace" token - start collecting the namespace name + if ($token[0] === T_NAMESPACE) { + $namespaceStarted = true; + $namespace = ''; + } + + // "class" token - start collecting the class name which is being defined + if ($token[0] === T_CLASS) { + $classNameStarted = true; + $className = ''; + } + } + + // Return list of the classes found + return $classes; + } +} diff --git a/src/Generate/RepositoriesGenerateCommand.php b/src/Generate/RepositoriesGenerateCommand.php new file mode 100644 index 0000000..b476c7f --- /dev/null +++ b/src/Generate/RepositoriesGenerateCommand.php @@ -0,0 +1,389 @@ +repository = $repository; + } + + public function select(): \{namespaceOfBuilders}\SelectEntries + { + return new \{namespaceOfBuilders}\SelectEntries($this->repository); + } + + public function count(): \Squirrel\Entities\Action\CountEntries + { + return new \Squirrel\Entities\Action\CountEntries($this->repository); + } + } +} + +namespace {namespaceOfBuilders} { + /* + * This class exists to have proper type hints about the object(s) returned in the + * getEntries and getOneEntry functions. All calls are delegated to the + * SelectEntries class - because of the builder pattern we cannot extend SelectEntries + * (because then returning self would return that class instead of this extended class) + * so we instead imitate it. This way the implementation in SelectEntries can change + * and this generated class has no ties to how it "works" or how the repository is used. + */ + class SelectEntries implements \Squirrel\Entities\Action\ActionInterface + { + /** + * @var \Squirrel\Entities\Action\SelectEntries + */ + private $selectImplementation; + + public function __construct(\Squirrel\Entities\RepositoryReadOnlyInterface $repository) + { + $this->selectImplementation = new \Squirrel\Entities\Action\SelectEntries($repository); + } + + public function field(string $onlyGetThisField): self + { + $this->selectImplementation->field($onlyGetThisField); + return $this; + } + + public function fields(array $onlyGetTheseFields): self + { + $this->selectImplementation->fields($onlyGetTheseFields); + return $this; + } + + public function where(array $whereClauses): self + { + $this->selectImplementation->where($whereClauses); + return $this; + } + + /** + * @param array|string $orderByClauses + * @return SelectEntries + */ + public function orderBy($orderByClauses): self + { + $this->selectImplementation->orderBy($orderByClauses); + return $this; + } + + public function startAt(int $startAtNumber): self + { + $this->selectImplementation->startAt($startAtNumber); + return $this; + } + + public function limitTo(int $numberOfEntries): self + { + $this->selectImplementation->limitTo($numberOfEntries); + return $this; + } + + public function blocking(bool $active = true): self + { + $this->selectImplementation->blocking($active); + return $this; + } + + /** + * @return \{namespaceOfEntity}\{classOfEntity}[] + */ + public function getEntries(): array + { + return $this->selectImplementation->getEntries(); + } + + public function getOneEntry(): ?\{namespaceOfEntity}\{classOfEntity} + { + return $this->selectImplementation->getOneEntry(); + } + + /** + * @return string[]|int[]|float[]|bool[]|null[] + */ + public function getFlattenedFields(): array + { + return $this->selectImplementation->getFlattenedFields(); + } + } +} + +EOD; + + /** + * @var string + */ + private $repositoryPhpFileBlueprintWriteable = <<<'EOD' +repository = $repository; + parent::__construct($repository); + } + + public function insert(): \Squirrel\Entities\Action\InsertEntry + { + return new \Squirrel\Entities\Action\InsertEntry($this->repository); + } + + public function insertOrUpdate(): \Squirrel\Entities\Action\InsertOrUpdateEntry + { + return new \Squirrel\Entities\Action\InsertOrUpdateEntry($this->repository); + } + + public function update(): \Squirrel\Entities\Action\UpdateEntries + { + return new \Squirrel\Entities\Action\UpdateEntries($this->repository); + } + + public function delete(): \Squirrel\Entities\Action\DeleteEntries + { + return new \Squirrel\Entities\Action\DeleteEntries($this->repository); + } + } +} + +EOD; + + /** + * @param string[] $sourceCodeDirectories + */ + public function __construct(array $sourceCodeDirectories) + { + $this->findClassesWithAnnotation = new FindClassesWithAnnotation(); + $this->sourceCodeDirectories = $sourceCodeDirectories; + } + + /** + * @return string[] + */ + public function __invoke(): array + { + $log = []; + + // Initialize entity processor to find repository config + $entityProcessor = new EntityProcessor(new AnnotationReader()); + + // Saves the files per path for which to create a .gitignore file + $gitignoreFilesForPaths = []; + + // Go through directories + foreach ($this->sourceCodeDirectories as $directory) { + // Find the files in the directory + $sourceFinder = new Finder(); + $sourceFinder->in($directory)->files()->name('*.php'); + + // Go through files which were found + foreach ($sourceFinder as $file) { + // Safety check because Finder can return false if the file was not found + if ($file->getRealPath()===false) { + throw new \InvalidArgumentException('File in source directory not found'); + } + + // Get file contents + $fileContents = \file_get_contents($file->getRealPath()); + + // Another safety check because file_get_contents can return false if the file was not found + if ($fileContents===false) { + throw new \InvalidArgumentException('File in source directory could not be retrieved'); + } + + // Get all possible entity classes with our annotation + $classes = $this->findClassesWithAnnotation->__invoke($fileContents); + + // Go through the possible entity classes + foreach ($classes as $class) { + // Divvy up the namespace and the class name + $namespace = $class[0]; + $className = $class[1]; + + // Get repository config as object from annotations + $repositoryConfig = $entityProcessor->process($namespace . '\\' . $className); + + // Repository config found - this is definitely an entity + if (isset($repositoryConfig)) { + $log[] = 'Entity found: ' . $namespace . '\\' . $className; + + // Replace all the variables in the repository classes blueprint + $replacementFunction = function ($repositoryPhpFile, $namespace, $className) { + $fullClassnameWithoutSeparator = \str_replace( + '\\', + '', + $namespace . $className + ); + $repositoryPhpFile = \str_replace( + '{namespaceOfEntity}', + $namespace, + $repositoryPhpFile + ); + $repositoryPhpFile = \str_replace( + '{namespaceOfBuilders}', + 'Squirrel\\Entities\\Action\\' . $fullClassnameWithoutSeparator, + $repositoryPhpFile + ); + $repositoryPhpFile = \str_replace( + '{classOfEntity}', + $className, + $repositoryPhpFile + ); + return $repositoryPhpFile; + }; + + // Compile file name and file contents for repository + $repositoryPhpFilename = $file->getPath() . '/' . + \str_replace( + '.php', + '', + $file->getFilename() + ) . 'RepositoryReadOnly.php'; + $repositoryPhpFile = $replacementFunction( + $this->repositoryPhpFileBlueprintReadOnly, + $namespace, + $className + ); + + // Save repository PHP file - only if it changed or doesn't exist yet + if (!\file_exists($repositoryPhpFilename) || + \file_get_contents($repositoryPhpFilename) !== $repositoryPhpFile + ) { + \file_put_contents($repositoryPhpFilename, $repositoryPhpFile); + } + + // Add PHP file to list for which we want to create a .gitignore file + $gitignoreFilesForPaths[$file->getPath()][] = \str_replace( + '.php', + '', + $file->getFilename() + ) . 'RepositoryReadOnly.php'; + + // Compile file name and file contents for repository + $repositoryPhpFilename = $file->getPath() . '/' . + \str_replace( + '.php', + '', + $file->getFilename() + ) . 'RepositoryWriteable.php'; + $repositoryPhpFile = $replacementFunction( + $this->repositoryPhpFileBlueprintWriteable, + $namespace, + $className + ); + + // Save repository PHP file - only if it changed or doesn't exist yet + if (!\file_exists($repositoryPhpFilename) || + \file_get_contents($repositoryPhpFilename) !== $repositoryPhpFile + ) { + \file_put_contents($repositoryPhpFilename, $repositoryPhpFile); + } + + // Add PHP file to list for which we want to create a .gitignore file + $gitignoreFilesForPaths[$file->getPath()][] = \str_replace( + '.php', + '', + $file->getFilename() + ) . 'RepositoryWriteable.php'; + } + } + } + } + + // Go through all paths where we created repository files + foreach ($gitignoreFilesForPaths as $path => $files) { + // Make sure all files are unique / no duplicates + $files = \array_unique($files); + + if (\count($files) > 0) { + // Ignore the .gitignore file in entity directories, that way both the gitignore + // and the repositories will be ignored by VCS + $gitignoreContents = [ + '.gitignore', + ]; + + // Add each repository file to .gitignore + foreach ($files as $filename) { + $gitignoreContents[] = $filename; + } + + // Save .gitignore file in the appropriate path + \file_put_contents( + $path . '/.gitignore', + \implode("\n", $gitignoreContents) + ); + } + } + + return $log; + } +} diff --git a/src/MultiRepositoryBuilderReadOnly.php b/src/MultiRepositoryBuilderReadOnly.php new file mode 100644 index 0000000..919e46a --- /dev/null +++ b/src/MultiRepositoryBuilderReadOnly.php @@ -0,0 +1,44 @@ +multiRepositoryReadOnly = $multiRepositoryReadOnly; + } + + /** + * @inheritDoc + */ + public function select(): MultiSelectEntries + { + return new MultiSelectEntries($this->multiRepositoryReadOnly); + } + + /** + * @inheritDoc + */ + public function selectFreeform(): MultiSelectEntriesFreeform + { + return new MultiSelectEntriesFreeform($this->multiRepositoryReadOnly); + } + + /** + * @inheritDoc + */ + public function count(): MultiCountEntries + { + return new MultiCountEntries($this->multiRepositoryReadOnly); + } +} diff --git a/src/MultiRepositoryBuilderReadOnlyInterface.php b/src/MultiRepositoryBuilderReadOnlyInterface.php new file mode 100644 index 0000000..3f44f83 --- /dev/null +++ b/src/MultiRepositoryBuilderReadOnlyInterface.php @@ -0,0 +1,25 @@ +multiRepositoryWriteable = $multiRepositoryWriteable; + parent::__construct($multiRepositoryWriteable); + } + + /** + * @inheritDoc + */ + public function update(): MultiUpdateEntries + { + return new MultiUpdateEntries($this->multiRepositoryWriteable); + } + + /** + * @inheritDoc + */ + public function updateFreeform(): MultiUpdateEntriesFreeform + { + return new MultiUpdateEntriesFreeform($this->multiRepositoryWriteable); + } +} diff --git a/src/MultiRepositoryBuilderWriteableInterface.php b/src/MultiRepositoryBuilderWriteableInterface.php new file mode 100644 index 0000000..75346f4 --- /dev/null +++ b/src/MultiRepositoryBuilderWriteableInterface.php @@ -0,0 +1,19 @@ +selectQueryFreeform($query); + } + + // Regular structured query + return $this->selectQuery($query); + } + + private function selectQueryFreeform(array $options, bool $flattenFields = false): array + { + // Process options and make sure all values are valid + [ + $sanitizedOptions, + $tableName, + $objectToTableFields, + $objectTypes, + $objectTypesNullable, + ] = $this->processOptions([ + 'repositories' => [], + 'fields' => [], + 'query' => '', + 'parameters' => [], + ], $options); + + // Process the query + $sqlQuery = $this->buildFreeform($sanitizedOptions['query'], $tableName, $objectToTableFields); + + // Build select part of the query + [$selectProcessed, $selectTypes, $selectTypesNullable] = $this->buildFieldSelection( + $sanitizedOptions['fields'], + $objectToTableFields, + $objectTypes, + $objectTypesNullable, + true + ); + + // Get all the data from the database + try { + $tableObjects = $this->db->fetchAll( + 'SELECT ' . \implode(',', $selectProcessed) . ' FROM ' . $sqlQuery, + $sanitizedOptions['parameters'] + ); + } catch (DBInvalidOptionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + $e->getMessage() + ); + } + + // Process the select results + return $this->processSelectResults($tableObjects, $selectTypes, $selectTypesNullable, $flattenFields); + } + + /** + * 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 + * @param bool $writing Whether this is a writing operation or not + * @return array + */ + protected function processOptions(array $validOptions, array $options, bool $writing = false) + { + // Reset DB class - needs to be set by the current options + $dbInstance = null; + + // Copy over the default valid options as a starting point for our options + $sanitizedOptions = $validOptions; + + // Go through the defined options + foreach ($options as $optKey => $optVal) { + // Defined option is not in the list of valid options + if (!isset($validOptions[$optKey])) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Unknown option key ' . DBDebug::sanitizeData($optKey) + ); + } + + // Make sure the variable type for the defined option is valid + switch ($optKey) { + // These are checked & converted by SQL component + case 'limit': + case 'offset': + case 'lock': + break; + // Handled in a special way by this class + 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, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Option key ' . DBDebug::sanitizeData($optKey) . + ' had an invalid value which cannot be converted correctly' + ); + } + + $options[$optKey] = \boolval($optVal); + break; + // Already type hinted "query" as string + case 'query': + break; + default: + if (!\is_array($optVal)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Option key ' . DBDebug::sanitizeData($optKey) . + ' had a non-array value: ' . DBDebug::sanitizeData($optVal) + ); + } + break; + } + + $sanitizedOptions[$optKey] = $optVal; + } + + // Make sure tables array was defined + if (!isset($sanitizedOptions['repositories']) || \count($sanitizedOptions['repositories']) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'No repositories specified' + ); + } + + // No table joins defined - just join them by "default" via repositories definition + if (isset($validOptions['tables']) && \count($sanitizedOptions['tables']) === 0) { + $sanitizedOptions['tables'] = \array_keys($sanitizedOptions['repositories']); + } + + // WHERE needs some restrictions to glue the tables together + if (isset($validOptions['where']) && \count($sanitizedOptions['where']) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'No "where" definitions' + ); + } + + // SELECT fields need to be defined + if (isset($validOptions['fields']) && \count($sanitizedOptions['fields']) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'No "fields" definition' + ); + } + + // SET changes in update query need to be defined + if (isset($validOptions['changes']) && \count($sanitizedOptions['changes']) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'No "changes" / SET definition' + ); + } + + // Query in freeform selects and updates needs to not be empty + if (isset($validOptions['query']) && \strlen($sanitizedOptions['query']) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'No "query" definition' + ); + } + + // Make sure parameters for a freestyle query are valid + if (isset($validOptions['parameters']) && \count($sanitizedOptions['parameters']) > 0) { + // Remove keys from parameters - they are not needed + $sanitizedOptions['parameters'] = \array_values($sanitizedOptions['parameters']); + + // Check all provided parameters + foreach ($sanitizedOptions['parameters'] as $key => $value) { + // Only scalar values are allowed + if (!\is_scalar($value)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Non-scalar "parameters" definition' + ); + } + + // Convert bool to int + if (\is_bool($value)) { + $value = \intval($value); + } + + $sanitizedOptions['parameters'][$key] = $value; + } + } + + /** + * Name of the tables for this query + * + * @var string + */ + $tableName = []; + + /** + * Conversion from object to table fields + * + * @var array + */ + $objectToTableFields = []; + + /** + * Types of the variables in the object for type casting + * + * @var array + */ + $objectTypes = []; + + /** + * Whether variables can be NULL or not + * + * @var array + */ + $objectTypesNullable = []; + + // Go through tables to prepare the repositories + foreach ($sanitizedOptions['repositories'] as $name => $class) { + // Make sure every entry in the tables array is valid + if (!\is_string($name) || \strpos($name, '.') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "repositories" key definition: ' . DBDebug::sanitizeData($name) + ); + } elseif ($class instanceof RepositoryBuilderReadOnlyInterface) { + // Make sure the repository is writeable if we are doing a writing query + if ($writing === true && !($class instanceof RepositoryBuilderWriteableInterface)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Non-writeable "repositories" object definition for writing operation' + ); + } + + try { + // Dive into the repository builder class and get the raw repository behind it + $builderRepositoryReflection = new \ReflectionClass($class); + $builderRepositoryPropertyReflection = $builderRepositoryReflection->getProperty('repository'); + $builderRepositoryPropertyReflection->setAccessible(true); + $baseRepository = $builderRepositoryPropertyReflection->getValue($class); + + // Get configuration from within the base repository + $baseRepositoryReflection = new \ReflectionClass($baseRepository); + $baseRepositoryPropertyReflection = $baseRepositoryReflection->getProperty('config'); + $baseRepositoryPropertyReflection->setAccessible(true); + $class = $baseRepositoryPropertyReflection->getValue($baseRepository); + + // Get DBInterface from base repository + $baseRepositoryPropertyReflection = $baseRepositoryReflection->getProperty('db'); + $baseRepositoryPropertyReflection->setAccessible(true); + $dbClass = $baseRepositoryPropertyReflection->getValue($baseRepository); + + // Make sure all DBInterface instances are the same = the same connection is used + if (isset($dbInstance) && $dbClass !== $dbInstance) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Repositories have different database connections, combined query is not possible' + ); + } + + $dbInstance = $dbClass; + } catch (\ReflectionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Repository configuration could not be retrieved through reflection, ' . + 'repository class not as expected: ' . $e->getMessage() + ); + } + } elseif ($class instanceof RepositoryReadOnlyInterface) { + // Make sure the repository is writeable if we are doing a writing query + if ($writing === true && !($class instanceof RepositoryWriteableInterface)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Non-writeable "repositories" object definition for writing operation' + ); + } + + try { + $baseRepositoryReflection = new \ReflectionClass($class); + + // Get DBInterface from base repository + $baseRepositoryPropertyReflection = $baseRepositoryReflection->getProperty('db'); + $baseRepositoryPropertyReflection->setAccessible(true); + $dbClass = $baseRepositoryPropertyReflection->getValue($class); + + // Get configuration from within the base repository + $baseRepositoryPropertyReflection = $baseRepositoryReflection->getProperty('config'); + $baseRepositoryPropertyReflection->setAccessible(true); + $class = $baseRepositoryPropertyReflection->getValue($class); + + // Make sure all DBInterface instances are the same = the same connection is used + if (isset($dbInstance) && $dbClass !== $dbInstance) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Repositories have different database connections, combined query is not possible' + ); + } + + $dbInstance = $dbClass; + } catch (\ReflectionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Repository configuration could not be retrieved through reflection, ' . + 'repository class not as expected: ' . $e->getMessage() + ); + } + } else { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid repository specified, does not implement ' . + 'RepositoryReadOnlyInterface or RepositoryBuilderReadOnlyInterface: ' . + DBDebug::sanitizeData($sanitizedOptions['repositories']) + ); + } + + // Name of the table + $tableName[$name] = $class->getTableName(); + + // Conversion from object to table fields + $objectToTableFields[$name] = $class->getObjectToTableFields(); + + // Types of the variables in the object for type casting + $objectTypes[$name] = $class->getObjectTypes(); + + // If a variable can be NULL or not + $objectTypesNullable[$name] = $class->getObjectTypesNullable(); + } + + if ($dbInstance instanceof DBInterface) { + $this->db = $dbInstance; + } else { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Repositories did not contain a valid database connection' . + DBDebug::sanitizeData($sanitizedOptions['repositories']) + ); + } + + // Remove repositories data - not needed for query to DBInterface + unset($sanitizedOptions['repositories']); + + // Return all processed options and object-to-table information + return [$sanitizedOptions, $tableName, $objectToTableFields, $objectTypes, $objectTypesNullable]; + } + + /** + * Build freeform query by replacing object names and object field names with the + * actual table names and table field names + * + * @param string $query + * @param array $tableName + * @param array $objectToTableFields + * @return string + */ + protected function buildFreeform(string $query, array $tableName, array $objectToTableFields) + { + // Replace all expressions of all involved repositories + foreach ($objectToTableFields as $table => $tableFields) { + // Replace table name placeholders + $query = \str_replace( + ':' . $table . ':', + $this->db->quoteIdentifier($tableName[$table]) . ' ' . $this->db->quoteIdentifier($table), + $query, + $count + ); + + // Replace all table fields with correct values + foreach ($tableFields as $objFieldName => $sqlFieldName) { + $query = \str_replace( + ':' . $table . '.' . $objFieldName . ':', + $this->db->quoteIdentifier($table . '.' . $sqlFieldName), + $query, + $count + ); + } + } + + // If we still have unresolved expressions, something went wrong + if (\strpos($query, ':') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "query" definition, unresolved colons remain' + ); + } + + // Return processed SQL query + return $query; + } + + /** + * Build SELECT part of the query + * + * @param array $selectOptions + * @param array $objectToTableFields + * @param array $objectTypes + * @param array $objectTypesNullable + * @param bool $generateSql + * @return array + */ + private function buildFieldSelection( + array $selectOptions, + array $objectToTableFields, + array $objectTypes, + array $objectTypesNullable, + bool $generateSql = false + ) { + // Calculated select fields + $selectProcessed = []; + $selectTypes = []; + $selectTypesNullable = []; + + // Go through all the select fields + foreach ($selectOptions as $name => $field) { + // No custom name for the field + if (\is_int($name)) { + $name = $field; + } + + // Name always has to be a string + if (!\is_string($name)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "fields" definition, key is not a string: ' . DBDebug::sanitizeData($name) + ); + } + + // Field always has to be a string + if (!\is_string($field)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "fields" definition, value for ' . + DBDebug::sanitizeData($name) . ' is not a string: ' . DBDebug::sanitizeData($field) + ); + } + + // No expressions allowed in name part! + if (\strpos($name, ':') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "fields" definition, name ' . + DBDebug::sanitizeData($name) . ' contains a colon' + ); + } + + // Special case of COUNT(*) - unlike any other SQL expression, and it should work + if (\strtoupper($field) === 'COUNT(*)') { + $selectProcessed[] = $field . ' AS ' . '"' . $name . '"'; + $selectTypes[$name] = 'int'; + } elseif (\strpos($field, ':') === false) { // No expression in field part + // Get separated table and field parts + $fieldParts = \explode('.', $field); + + // Field does not exist in this way + if (!isset($fieldParts[1]) || !isset($objectToTableFields[$fieldParts[0]][$fieldParts[1]])) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "fields" definition, unknown field name: ' . + DBDebug::sanitizeData($field) + ); + } + + // We map the SQL field to the full object field (table.field) + if ($generateSql === true) { + $selectProcessed[] = $this->db->quoteIdentifier( + $fieldParts[0] . '.' . $objectToTableFields[$fieldParts[0]][$fieldParts[1]] + ) . ' AS "' . $name . '"'; + } else { + $selectProcessed[$name] = $fieldParts[0] . '.' . + $objectToTableFields[$fieldParts[0]][$fieldParts[1]]; + } + $selectTypes[$name] = $objectTypes[$fieldParts[0]][$fieldParts[1]]; + $selectTypesNullable[$name] = $objectTypesNullable[$fieldParts[0]][$fieldParts[1]]; + } else { // Expressions in field part + // The type guessed by the used table fields + $type = ''; + $nullable = false; + + // Replace all expressions of all involved repositories + foreach ($objectToTableFields as $table => $tableFields) { + foreach ($tableFields as $objFieldName => $sqlFieldName) { + $field = \str_replace( + ':' . $table . '.' . $objFieldName . ':', + $this->db->quoteIdentifier($table . '.' . $sqlFieldName), + $field, + $count + ); + + // Replacement occured, so this field name is used + if ($count > 0) { + // We narrow the type to bool if only bool values are used + if ($objectTypes[$table][$objFieldName] === 'bool' && $type === '') { + $type = 'bool'; + } elseif ($objectTypes[$table][$objFieldName] === 'int' && + ($type === '' || $type === 'bool') + ) { // We narrow the type to int if only int and bool values are used + $type = 'int'; + } elseif ($objectTypes[$table][$objFieldName] === 'float' && $type !== 'string') { + // If any float values are used, we use float type if there are no strings + $type = 'float'; + } elseif ($objectTypes[$table][$objFieldName] === 'string') { + // As soon as a string type is used we always use string type + $type = 'string'; + } + + // NULL is a possible value for this field + if ($objectTypesNullable[$table][$objFieldName] === true) { + $nullable = true; + } + } + } + } + + // If we still have unresolved expressions, something went wrong + if (\strpos($field, ':') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "fields" definition, unresolved colons: ' . + DBDebug::sanitizeData($field) + ); + } + + // We guess the type is string if we have a CONCAT or REPLACE in the string + if (\strpos($field, 'CONCAT') !== false || \strpos($field, 'REPLACE') !== false) { + $type = 'string'; + } + + // Assign the select expression + $selectProcessed[] = '(' . $field . ')' . ' AS ' . '"' . $name . '"'; + $selectTypes[$name] = $type; + $selectTypesNullable[$name] = $nullable; + } + } + + return [$selectProcessed, $selectTypes, $selectTypesNullable]; + } + + /** + * Process the results retrieved from a SELECT query + * + * @param array $tableObjects + * @param array $selectTypes + * @param array $selectTypesNullable + * @param bool $flattenFields + * @return array + */ + private function processSelectResults( + array $tableObjects, + array $selectTypes, + array $selectTypesNullable, + bool $flattenFields = false + ) { + // Go through result set + foreach ($tableObjects as $entryCount => $entry) { + foreach ($entry as $key => $value) { + // Special case of nullable types + if (\is_null($value) && $selectTypesNullable[$key] === true) { + $tableObjects[$entryCount][$key] = null; + continue; + } + + switch ($selectTypes[$key]) { + case 'int': + $tableObjects[$entryCount][$key] = \intval($value); + break; + case 'bool': + $tableObjects[$entryCount][$key] = \boolval($value); + break; + case 'float': + $tableObjects[$entryCount][$key] = \floatval($value); + break; + case 'string': + $tableObjects[$entryCount][$key] = \strval($value); + break; + default: + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Unknown casting for object variable ' . DBDebug::sanitizeData($key) + ); + } + } + } + + // Flatten all values into a one-dimensional array + if ($flattenFields === true) { + $list = []; + + // Go through table results + foreach ($tableObjects as $objIndex => $tableObject) { + // Go through all table fields + foreach ($tableObject as $fieldName => $fieldValue) { + $list[] = $fieldValue; + } + } + + return $list; + } + + return $tableObjects; + } + + private function selectQuery(array $query, bool $flattenFields = false): array + { + // Process options and make sure all values are valid + [ + $sanitizedOptions, + $tableName, + $objectToTableFields, + $objectTypes, + $objectTypesNullable, + ] = $this->processOptions([ + 'repositories' => [], + 'fields' => [], + 'tables' => [], + 'where' => [], + 'group' => [], + 'order' => [], + 'limit' => 0, + 'offset' => 0, + 'flattenFields' => false, + 'lock' => false, + ], $query); + + // Only handle flattening in this class, do not pass it along + $flattenFields = ($sanitizedOptions['flattenFields'] || $flattenFields === true); + unset($sanitizedOptions['flattenFields']); + + // Build SELECT part of the query + [ + $sanitizedOptions['fields'], + $selectTypes, + $selectTypesNullable, + ] = $this->buildFieldSelection( + $sanitizedOptions['fields'], + $objectToTableFields, + $objectTypes, + $objectTypesNullable + ); + + // List of finished FROM expressions, to be imploded with , + possible query values + $sanitizedOptions['tables'] = $this->preprocessJoins( + $sanitizedOptions['tables'], + $tableName, + $objectToTableFields + ); + + // List of finished WHERE expressions, to be imploded with ANDs + $sanitizedOptions['where'] = $this->preprocessWhere($sanitizedOptions['where'], $objectToTableFields); + + // GROUP BY was defined + if (isset($sanitizedOptions['group']) && \count($sanitizedOptions['group']) > 0) { + $sanitizedOptions['group'] = $this->preprocessGroup($sanitizedOptions['group'], $objectToTableFields); + } else { + unset($sanitizedOptions['group']); + } + + // Order was defined + if (isset($sanitizedOptions['order']) && \count($sanitizedOptions['order']) > 0) { + $sanitizedOptions['order'] = $this->preprocessOrder($sanitizedOptions['order'], $objectToTableFields); + } else { + unset($sanitizedOptions['order']); + } + + // No limit - remove it from options + if ($sanitizedOptions['limit'] === 0) { + unset($sanitizedOptions['limit']); + } + + // No offset - remove it from options + if ($sanitizedOptions['offset'] === 0) { + unset($sanitizedOptions['offset']); + } + + // No lock - remove it from options + if ($sanitizedOptions['lock'] === false) { + unset($sanitizedOptions['lock']); + } + + // Get all the data from the database + try { + $tableObjects = $this->db->fetchAll($sanitizedOptions); + } catch (DBInvalidOptionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + $e->getMessage() + ); + } + + // Process the select results + return $this->processSelectResults($tableObjects, $selectTypes, $selectTypesNullable, $flattenFields); + } + + /** + * Prepare the joins between tables part for the SQL component + * + * @param array $tables + * @param array $tableNames + * @param array $objectToTableFields + * @return array + */ + protected function preprocessJoins(array $tables, array $tableNames, array $objectToTableFields) + { + // List of table selection, needs to be imploded with a comma for SQL query + $tablesProcessed = []; + + // Go through table selection + foreach ($tables as $expression => $values) { + // No values, only an expression + if (\is_int($expression)) { + $expression = $values; + $values = null; + } + + // Expression always has to be a string + if (!\is_string($expression)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "tables" / table join definition, expression is not a string: ' . + DBDebug::sanitizeData($expression) + ); + } + + // No expression, only a table name + if (\strpos($expression, ':') === false) { + // Make sure the table alias exists + if (!isset($tableNames[$expression])) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "tables" / table join definition, alias not found: ' . + DBDebug::sanitizeData($expression) + ); + } + + // Quoting not necessary, will be handled by SQL component + $tablesProcessed[] = $tableNames[$expression] . ' ' . $expression; + } else { // An expression with : variables + // Replace all expressions of all involved repositories + foreach ($objectToTableFields as $table => $tableFields) { + foreach ($tableFields as $objFieldName => $sqlFieldName) { + $expression = \str_replace( + ':' . $table . '.' . $objFieldName . ':', + $this->db->quoteIdentifier($table . '.' . $sqlFieldName), + $expression, + $count + ); + } + } + + // Replace all table names and insert the aliases + foreach ($tableNames as $tableNameAlias => $tableNameReal) { + $expression = \str_replace( + ':' . $tableNameAlias . ':', + $this->db->quoteIdentifier($tableNameReal) . ' ' . $this->db->quoteIdentifier($tableNameAlias), + $expression + ); + } + + // If we still have unresolved expressions, something went wrong + if (\strpos($expression, ':') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "tables" / table join definition, ' . + 'unconverted objects/table names found in expression: ' . + DBDebug::sanitizeData($expression) + ); + } + + // Add expression to from tables + if ($values === null) { + $tablesProcessed[] = $expression; + } else { + $tablesProcessed[$expression] = $values; + } + } + } + + return $tablesProcessed; + } + + /** + * Prepare the WHERE clauses for SQL component + * + * @param array $whereOptions + * @param array $objectToTableFields + * @return array + */ + protected function preprocessWhere(array $whereOptions, array $objectToTableFields) + { + // 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 = null; + } + + // Expression always has to be a string + if (!\is_string($expression)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "where" definition, expression is not a string: ' . + DBDebug::sanitizeData($expression) + ); + } + + // No expression, only a table field name + if (\strpos($expression, ':') === false) { + // Values have to be defined for us to make a predefined equals query + if (isset($values)) { + // Get separated table and field parts + $fieldParts = \explode('.', $expression); + + // Field was not found + if (!isset($fieldParts[1]) || !isset($objectToTableFields[$fieldParts[0]][$fieldParts[1]])) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "where" definition, field name not found: ' . + DBDebug::sanitizeData($expression) + ); + } + + // Convert field name + $expression = $fieldParts[0] . '.' . $objectToTableFields[$fieldParts[0]][$fieldParts[1]]; + } + } else { // Freestyle expression + // Replace all expressions of all involved repositories + foreach ($objectToTableFields as $table => $tableFields) { + foreach ($tableFields as $objFieldName => $sqlFieldName) { + $expression = \str_replace( + ':' . $table . '.' . $objFieldName . ':', + $this->db->quoteIdentifier($table . '.' . $sqlFieldName), + $expression, + $count + ); + } + } + + // If we still have unresolved expressions, something went wrong + if (\strpos($expression, ':') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "where" definition, unresolved colons remain in expression: ' . + DBDebug::sanitizeData($expression) + ); + } + } + + // Add the where definition to the processed list + if (isset($values)) { + $whereProcessed[$expression] = $values; + } else { + $whereProcessed[] = $expression; + } + } + + return $whereProcessed; + } + + /** + * Build GROUP BY clause and add query values + * + * @param array $groupByOptions + * @param array $objectToTableFields + * @return array + */ + private function preprocessGroup(array $groupByOptions, array $objectToTableFields) + { + // 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, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "group" / group by definition, expression is not a string: ' . + DBDebug::sanitizeData($expression) + ); + } + + // No expression, only a table field name + if (\strpos($expression, ':') === false) { + // Get separated table and field parts + $fieldParts = \explode('.', $expression); + + // Field was not found + if (!isset($objectToTableFields[$fieldParts[0]][$fieldParts[1]])) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "group" / group by definition, field name not found in any repository: ' . + DBDebug::sanitizeData($expression) . ' within ' . DBDebug::sanitizeData($groupByOptions) + ); + } + } else { // Freestyle expression - not allowed + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "group" / group by definition, no variables are allowed in expression: ' . + DBDebug::sanitizeData($expression) + ); + } + + // Add to list of finished expressions + $groupByProcessed[] = $fieldParts[0] . '.' . $objectToTableFields[$fieldParts[0]][$fieldParts[1]]; + } + + return $groupByProcessed; + } + + /** + * Prepare the ORDER BY clauses for SQL component + * + * @param array $orderOptions + * @param array $objectToTableFields + * @return array + */ + protected function preprocessOrder(array $orderOptions, array $objectToTableFields) + { + // List of finished WHERE expressions, to be imploded with ANDs + $orderProcessed = []; + + // Go through table selection + foreach ($orderOptions as $expression => $direction) { + // If there is no explicit order we set it to ASC + if (\is_int($expression)) { + $expression = $direction; + $direction = null; + } + + // Expression always has to be a string + if (!\is_string($expression)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "order" / order by definition, expression is not a string: ' . + DBDebug::sanitizeData($expression) + ); + } + + // No expression, only a table field name + if (\strpos($expression, ':') === false) { + // Get separated table and field parts + $fieldParts = \explode('.', $expression); + + // Field was found - convert it + if (count($fieldParts) === 2 && isset($objectToTableFields[$fieldParts[0]][$fieldParts[1]])) { + $expression = $fieldParts[0] . '.' . $objectToTableFields[$fieldParts[0]][$fieldParts[1]]; + } + } else { // Freestyle expression + // Replace all field names with the sql field name and escape characters around it + foreach ($objectToTableFields as $table => $tableFields) { + foreach ($tableFields as $objFieldName => $sqlFieldName) { + $expression = \str_replace( + ':' . $table . '.' . $objFieldName . ':', + chr(27) . $table . '.' . $sqlFieldName . chr(27), + $expression, + $count + ); + } + } + + // If we still have unresolved expressions, something went wrong + if (\strpos($expression, ':') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "order" / order by definition, unconverted object names found in expression: ' . + DBDebug::sanitizeData($expression) + ); + } + + // Replace the escape markers back to colons + $expression = str_replace(chr(27), ':', $expression); + } + + // Add order entry to processed list + if ($direction === null) { + $orderProcessed[] = $expression; + } else { + $orderProcessed[$expression] = $direction; + } + } + + return $orderProcessed; + } + + /** + * @inheritdoc + */ + public function selectOne(array $query): ?array + { + $query['limit'] = 1; + + // Return just the one retrieved entry + $results = $this->selectQuery($query); + return \array_pop($results); + } + + /** + * @inheritdoc + */ + public function selectFlattenedFields(array $query): array + { + // Freeform query was detected + if (isset($query['query']) || isset($query['parameters'])) { + return $this->selectQueryFreeform($query, true); + } + + // Regular structured query + return $this->selectQuery($query, true); + } +} diff --git a/src/MultiRepositoryReadOnlyInterface.php b/src/MultiRepositoryReadOnlyInterface.php new file mode 100644 index 0000000..6f7d085 --- /dev/null +++ b/src/MultiRepositoryReadOnlyInterface.php @@ -0,0 +1,59 @@ +updateQueryFreeform($query); + } + + // Regular structured query + return $this->updateQuery($query); + } + + private function updateQueryFreeform(array $query): int + { + // Process options and make sure all values are valid + [$sanitizedOptions, $tableName, $objectToTableFields] = $this->processOptions([ + 'repositories' => [], + 'query' => '', + 'parameters' => [], + ], $query, true); + + // Process the query + $sqlQuery = $this->buildFreeform($sanitizedOptions['query'], $tableName, $objectToTableFields); + + // Execute update query and return number of affected rows from the update + try { + return $this->db->change($sqlQuery, $sanitizedOptions['parameters']); + } catch (DBInvalidOptionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + $e->getMessage() + ); + } + } + + private function updateQuery(array $query): int + { + // Process options and make sure all values are valid + [$sanitizedOptions, $tableName, $objectToTableFields] = $this->processOptions([ + 'repositories' => [], + 'tables' => [], + 'changes' => [], + 'where' => [], + 'order' => [], + 'limit' => 0, + ], $query, true); + + // List of finished UPDATE expressions, to be imploded with , + possible query values + $sanitizedOptions['tables'] = $this->preprocessJoins( + $sanitizedOptions['tables'], + $tableName, + $objectToTableFields + ); + + // List of finished SET expressions, to be imploded with , + possible query values + $sanitizedOptions['changes'] = $this->preprocessChanges($sanitizedOptions['changes'], $objectToTableFields); + + // List of finished WHERE expressions, to be imploded with ANDs + $sanitizedOptions['where'] = $this->preprocessWhere($sanitizedOptions['where'], $objectToTableFields); + + // Order was defined + if (isset($sanitizedOptions['order']) && \count($sanitizedOptions['order']) > 0) { + $sanitizedOptions['order'] = $this->preprocessOrder($sanitizedOptions['order'], $objectToTableFields); + } else { + unset($sanitizedOptions['order']); + } + + // No limit - remove it from options + if ($sanitizedOptions['limit'] === 0) { + unset($sanitizedOptions['limit']); + } + + // Execute update query and return number of affected rows from the update + try { + return $this->db->update($sanitizedOptions); + } catch (DBInvalidOptionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + $e->getMessage() + ); + } + } + + /** + * Build update (SET) query part of all SQL queries + * + * @param array $changes + * @param array $objectToTableFields + * @return array + */ + private function preprocessChanges(array $changes, array $objectToTableFields) + { + // List of finished SET expressions, to be imploded with , + $changesProcessed = []; + + // Go through table selection + foreach ($changes as $expression => $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, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "changes" / SET definition, expression is not a string: ' . + DBDebug::sanitizeData($expression) + ); + } + + // No expression, only a table field name + if (\strpos($expression, ':') === false) { + // No variables and no values - we need one of either for a valid change + if (!isset($values)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "changes" / SET definition, no value(s) for fixed variable ' . + DBDebug::sanitizeData($expression) + ); + } + + // Get separated table and field parts + $fieldParts = \explode('.', $expression); + + // Field was not found + if (\count($fieldParts) <= 1 || !isset($objectToTableFields[$fieldParts[0]][$fieldParts[1]])) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "changes" / SET definition, field name was not found in repository: ' . + DBDebug::sanitizeData($expression) + ); + } + + // Convert field name + $expression = $fieldParts[0] . '.' . $objectToTableFields[$fieldParts[0]][$fieldParts[1]]; + } else { // Freestyle expression + // Replace all expressions of all involved repositories + foreach ($objectToTableFields as $table => $tableFields) { + foreach ($tableFields as $objFieldName => $sqlFieldName) { + $expression = \str_replace( + ':' . $table . '.' . $objFieldName . ':', + $this->db->quoteIdentifier($table . '.' . $sqlFieldName), + $expression, + $count + ); + } + } + + // If we still have unresolved expressions, something went wrong + if (\strpos($expression, ':') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [MultiRepositoryReadOnlyInterface::class, ActionInterface::class], + 'Unresolved colons in "changes" / SET clause: ' . + DBDebug::sanitizeData($expression) + ); + } + } + + // Add change entry to the processed list + if ($values === null) { + $changesProcessed[] = $expression; + } else { + $changesProcessed[$expression] = $values; + } + } + + return $changesProcessed; + } +} diff --git a/src/MultiRepositoryWriteableInterface.php b/src/MultiRepositoryWriteableInterface.php new file mode 100644 index 0000000..a73cedc --- /dev/null +++ b/src/MultiRepositoryWriteableInterface.php @@ -0,0 +1,34 @@ +connectionName = $connectionName; + $this->tableName = $tableName; + $this->tableToObjectFields = $tableToObjectFields; + $this->objectToTableFields = $objectToTableFields; + $this->objectClass = $objectClass; + $this->objectTypes = $objectTypes; + $this->objectTypesNullable = $objectTypesNullable; + } + + /** + * @return string + */ + public function getConnectionName(): string + { + return $this->connectionName; + } + + /** + * @inheritDoc + */ + public function getTableName(): string + { + return $this->tableName; + } + + /** + * @inheritDoc + */ + public function getTableToObjectFields(): array + { + return $this->tableToObjectFields; + } + + /** + * @inheritDoc + */ + public function getObjectToTableFields(): array + { + return $this->objectToTableFields; + } + + /** + * @inheritDoc + */ + public function getObjectClass(): string + { + return $this->objectClass; + } + + /** + * @inheritDoc + */ + public function getObjectTypes(): array + { + return $this->objectTypes; + } + + /** + * @inheritDoc + */ + public function getObjectTypesNullable(): array + { + return $this->objectTypesNullable; + } +} diff --git a/src/RepositoryConfigInterface.php b/src/RepositoryConfigInterface.php new file mode 100644 index 0000000..47b2c6a --- /dev/null +++ b/src/RepositoryConfigInterface.php @@ -0,0 +1,46 @@ +db = $db; + $this->config = $config; + $this->objectToTableFields = $config->getObjectToTableFields(); + $this->objectTypes = $config->getObjectTypes(); + $this->objectTypesNullable = $config->getObjectTypesNullable(); + } + + /** + * @inheritDoc + */ + public function selectOne(array $query) + { + if (isset($query['limit']) && $query['limit'] !== 1) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Row limit cannot be set for selectOne query: ' . DBDebug::sanitizeData($query) + ); + } + + $query['limit'] = 1; + + // Return found objects and just return the one + $results = $this->select($query); + return \array_pop($results); + } + + /** + * @inheritDoc + */ + public function select(array $query): array + { + // Process options and make sure all values are valid + $sanitizedQuery = $this->processOptions([ + 'where' => [], + 'order' => [], + 'limit' => 0, + 'offset' => 0, + 'fields' => [], + 'lock' => false, + ], $query); + + // Return found objects + return $this->selectQuery($sanitizedQuery); + } + + /** + * 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 + */ + protected function processOptions(array $validOptions, array $options) + { + // One field shortcut - convert to fields array + if (isset($validOptions['fields']) && isset($options['field']) && !isset($options['fields'])) { + $options['fields'] = [$options['field']]; + unset($options['field']); + } + + // Copy over the default valid options as a starting point for our options + $sanitizedOptions = $validOptions; + + // Go through the defined options + foreach ($options as $optKey => $optVal) { + // Defined option is not in the list of valid options + if (!isset($validOptions[$optKey])) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Unknown option key ' . DBDebug::sanitizeData($optKey) + ); + } + + // Make sure the variable type for the defined option is valid + switch ($optKey) { + // These are checked & converted by SQL component + case 'limit': + case 'offset': + case 'lock': + break; + default: + if (!\is_array($optVal)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Option key ' . DBDebug::sanitizeData($optKey) . + ' had a non-array value: ' . DBDebug::sanitizeData($optVal) + ); + } + break; + } + + $sanitizedOptions[$optKey] = $optVal; + } + + // Return all processed options and object-to-table information + return $sanitizedOptions; + } + + /** + * @param array $query + * @param bool $flattenFields Whether to flatten the return field values and remove field names + * @return array + */ + private function selectQuery(array $query, bool $flattenFields = false): array + { + // Set the table variable for SQL component + $query['table'] = $this->config->getTableName(); + + // Field names were restricted + if (\count($query['fields']) > 0) { + $query['fields'] = $this->convertNamesToTable($query['fields']); + } else { // Remove fields if none were defined + unset($query['fields']); + } + + // There are WHERE restrictions + if (\count($query['where']) > 0) { + $query['where'] = $this->preprocessWhere($query['where']); + } else { + unset($query['where']); + } + + // Order part of the query was defined + if (\count($query['order']) > 0) { + $query['order'] = $this->preprocessOrder($query['order']); + } else { + unset($query['order']); + } + + // No limit - remove it from options + if ($query['limit'] === 0) { + unset($query['limit']); + } + + // No offset - remove it from options + if ($query['offset'] === 0) { + unset($query['offset']); + } + + // No lock - remove it from options + if ($query['lock'] === false) { + unset($query['lock']); + } + + try { + // Get all the data from the database + $tableObjects = $this->db->fetchAll($query); + } catch (DBInvalidOptionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + $e->getMessage() + ); + } + + // The objects to return + $useableObjects = []; + + // Table rows were found + if (\is_array($tableObjects)) { + $tableToObjectFields = $this->config->getTableToObjectFields(); + + // Special case: Only one field name is retrieved. We reduce the + // values to an array of these values + if ($flattenFields === true) { + $list = []; + + // Go through table results + foreach ($tableObjects as $objIndex => $tableObject) { + // Go through all table fields + foreach ($tableObject as $fieldName => $fieldValue) { + $list[] = $this->castObjVariable($fieldValue, $tableToObjectFields[$fieldName]); + } + } + + return $list; + } + + // Get reflection information on the class + if (!isset($this->reflectionClass)) { + $this->reflectionClass = new \ReflectionClass($this->config->getObjectClass()); + } + + // Go through table results + foreach ($tableObjects as $objIndex => $tableObject) { + // Initialize object without constructor + $useableObjects[$objIndex] = $this->reflectionClass->newInstanceWithoutConstructor(); + + // Go through all table + foreach ($tableObject as $fieldName => $fieldValue) { + // We ignore unknown table fields + if (!isset($tableToObjectFields[$fieldName])) { + continue; + } + + // Get object key + $objKey = $tableToObjectFields[$fieldName]; + + // Get reflection property, make is accessible to reflection and cache it + if (!isset($this->reflectionProperties[$objKey])) { + $this->reflectionProperties[$objKey] = $this->reflectionClass->getProperty($objKey); + $this->reflectionProperties[$objKey]->setAccessible(true); + } + + // Set the property via reflection + $this->reflectionProperties[$objKey] + // Set new value for our current object + ->setValue( + $useableObjects[$objIndex], + // Cast the new value to the correct type (string, int, float, bool) + $this->castObjVariable($fieldValue, $tableToObjectFields[$fieldName]) + ); + } + } + } + + // Return found objects + return $useableObjects; + } + + /** + * Convert field names to the table names + * + * @param array $fieldNames + * @return array + * + * @throws DBInvalidOptionException + */ + protected function convertNamesToTable(array $fieldNames) + { + // Result of converted names + $convertedNames = []; + + // Go through all provided field names + foreach ($fieldNames as $fieldName) { + // If we do not know a field name this is super bad + if (!\is_string($fieldName) || !isset($this->objectToTableFields[$fieldName])) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Unknown field name: ' . DBDebug::sanitizeData($fieldName) + ); + } + + // Convert the name + $convertedNames[] = $this->objectToTableFields[$fieldName]; + } + + // Return the converted fields + return $convertedNames; + } + + /** + * Prepare the WHERE clauses for SQL component + * + * @param array $where + * @return array + * + * @throws DBInvalidOptionException + */ + protected function preprocessWhere(array $where) + { + // SQL restrictions as an array + $whereProcessed = []; + + // Go through all where clauses + foreach ($where as $whereName => $whereValue) { + // Switch name and values if necessary + if (\is_int($whereName)) { + $whereName = $whereValue; + $whereValue = []; + } + + // Make sure we have a valid field name + if (!\is_string($whereName)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "where" definition, expression is not a string: ' . + DBDebug::sanitizeData($whereName) + ); + } + + // Key contains a colon, meaning this is a string query part + if (\strpos($whereName, ':') !== false) { + // Cast variable values + $whereValue = $this->castTableVariable($whereValue); + + // Convert all :variable values from object to table notation + $whereName = $this->convertNamesToTableInString($whereName); + + // Variables still exist which were not resolved + if (\strpos($whereName, ':') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Unresolved colons in "where" clause: ' . + DBDebug::sanitizeData($whereName) + ); + } + } else { // Key is a string, meaning normal field - value entry + // Cast variable values + $whereValue = $this->castTableVariable($whereValue, $whereName); + + // Convert where field name + $whereName = $this->convertNameToTable($whereName); + } + + // Add the where definition to the processed list + if (\is_array($whereValue) && \count($whereValue) === 0) { + $whereProcessed[] = $whereName; + } else { + $whereProcessed[$whereName] = $whereValue; + } + } + + // Returned generated SQL and the new where values + return $whereProcessed; + } + + /** + * Cast an object variable (array or scalar) to the correct type for a SQL query + * + * @param mixed $value + * @param null|string $fieldName + * @return int|float|string|array|null + * + * @throws DBInvalidOptionException + */ + protected function castTableVariable($value, ?string $fieldName = null) + { + // Array - go through elements and cast them + if (\is_array($value)) { + foreach ($value as $key => $valueSub) { + $value[$key] = $this->castOneTableVariable($valueSub, $fieldName); + } + } else { // Single scalar value - cast it + $value = $this->castOneTableVariable($value, $fieldName); + } + + return $value; + } + + /** + * Cast an object variable (only single scalar) to the correct type for a SQL query + * + * @param mixed $value + * @param string|null $fieldName + * @return int|float|string|null + * + * @throws DBInvalidOptionException + */ + protected function castOneTableVariable($value, ?string $fieldName = null) + { + // Only scalar values and null are allowed + if (!\is_null($value) && !\is_scalar($value)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid value for field name: ' . + DBDebug::sanitizeData($fieldName) . ' => ' . DBDebug::sanitizeData($value) + ); + } + + // If we are "blind" to the exact field name we just make sure boolean + // values are converted to int + // This case only occurs with "freeform" query parts where there can be multiple variables + // involved and we do not really know which, so we cannot help the user by type casting, the + // user is on his own + if (!isset($fieldName)) { + if (\is_bool($value)) { + $value = \intval($value); + } + + return $value; + } + + // Make sure we know the used field name + if (!isset($this->objectTypes[$fieldName])) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Unknown field name: ' . DBDebug::sanitizeData($fieldName) + ); + } + + // Check for null value and if it is allowed for this field name + if (\is_null($value)) { + // Not allowed + if ($this->objectTypesNullable[$fieldName] !== true) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'NULL value for non-nullable field name: ' . + DBDebug::sanitizeData($fieldName) + ); + } + + return $value; + } + + // We know the field type - only basic types allowed + switch ($this->objectTypes[$fieldName]) { + case 'int': + return \intval($value); + case 'bool': + return \intval(\boolval($value)); + case 'float': + return \floatval($value); + case 'string': + return \strval($value); + } + + // Always throw an exception we if hit unchartered territory + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Unknown casting for object variable: ' . DBDebug::sanitizeData($fieldName) + ); + } + + /** + * Convert :name: notations in strings from object to table notation + * + * @param string $expression + * @return string + */ + protected function convertNamesToTableInString(string $expression) + { + // Convert all :variable: values from object to table notation + foreach ($this->objectToTableFields as $objectName => $tableName) { + $expression = \str_replace(':' . $objectName . ':', $this->db->quoteIdentifier($tableName), $expression); + } + + return $expression; + } + + /** + * Convert field name to the table name + * + * @param string $fieldName + * @return string + * + * @throws DBInvalidOptionException + */ + protected function convertNameToTable(string $fieldName) + { + // If we do not know a field name this is super bad + if (!isset($this->objectToTableFields[$fieldName])) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Unknown field name: ' . DBDebug::sanitizeData($fieldName) + ); + } + + return $this->objectToTableFields[$fieldName]; + } + + /** + * Prepare the ORDER BY clauses for SQL component + * + * @param array $orderOptions + * @return array + * + * @throws DBInvalidOptionException + */ + protected function preprocessOrder(array $orderOptions) + { + // Order SQL parts + $orderProcessed = []; + + // Go through all order contraints and apply them + foreach ($orderOptions as $fieldName => $direction) { + // Key is a number, so we need to switch fieldName and set a default direction + if (\is_int($fieldName)) { + $fieldName = $direction; + $direction = null; + } + + // Make sure we have a valid fieldname + if (!\is_string($fieldName)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "order" / order by definition, expression is not a string: ' . + DBDebug::sanitizeData($fieldName) + ); + } + + // Variables were defined, so a freestyle order + if (\strpos($fieldName, ':') !== false) { + // Convert all :variable: values from object to table notation + $fieldName = $this->convertNamesToTableInString($fieldName); + + // Variables still exist which were not resolved + if (\strpos($fieldName, ':') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Unresolved colons in "order" / order by clause: ' . + DBDebug::sanitizeData($fieldName) + ); + } + } else { // Specific field name + $fieldName = $this->convertNameToTable($fieldName); + } + + // Add order entry to processed list + if ($direction === null) { + $orderProcessed[] = $fieldName; + } else { + $orderProcessed[$fieldName] = $direction; + } + } + + return $orderProcessed; + } + + /** + * Cast an object variable to the correct type for use in an object + * + * @param mixed $value + * @param string $fieldName + * @return bool|int|float|string|null + * + * @throws DBInvalidOptionException + */ + protected function castObjVariable($value, string $fieldName) + { + // Field is null and can be null according to config + if (\is_null($value) && $this->objectTypesNullable[$fieldName] === true) { + return $value; + } + + switch ($this->objectTypes[$fieldName]) { + case 'int': + return \intval($value); + case 'bool': + return \boolval($value); + case 'float': + return \floatval($value); + case 'string': + return \strval($value); + } + + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Unknown casting for object variable: ' . DBDebug::sanitizeData($fieldName) + ); + } + + /** + * @inheritDoc + */ + public function selectFlattenedFields(array $query): array + { + // Process options and make sure all values are valid + $sanitizedQuery = $this->processOptions([ + 'where' => [], + 'order' => [], + 'limit' => 0, + 'offset' => 0, + 'fields' => [], + 'lock' => false, + ], $query); + + // Return found objects + return $this->selectQuery($sanitizedQuery, true); + } + + /** + * @inheritDoc + */ + public function count(array $query): int + { + // Basic query counting the rows + $query = [ + 'table' => $this->config->getTableName(), + 'fields' => [ + 'num' => 'COUNT(*)', + ], + 'where' => $this->preprocessWhere($query['where'] ?? []), + 'lock' => $query['lock'] ?? false, + ]; + + // Remove empty WHERE restrictions + if (\count($query['where']) === 0) { + unset($query['where']); + } + + try { + // Get the number from the database + $count = $this->db->fetchOne($query); + } catch (DBInvalidOptionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + $e->getMessage() + ); + } + + // Return count as int + return \intval($count['num']); + } +} diff --git a/src/RepositoryReadOnlyInterface.php b/src/RepositoryReadOnlyInterface.php new file mode 100644 index 0000000..9912e04 --- /dev/null +++ b/src/RepositoryReadOnlyInterface.php @@ -0,0 +1,66 @@ +processOptions([ + 'changes' => [], + 'where' => [], + 'order' => [], + 'limit' => 0, + ], $query); + + // We need specific WHERE restrictions, otherwise there is a huge risk + // of overwriting to many entries + if (\count($sanitizedQuery['where']) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'No restricting "where" defined' + ); + } + + // We need fields to update, otherwise there is nothing to do + if (\count($sanitizedQuery['changes']) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'No "changes" / SET clause defined' + ); + } + + $sanitizedQuery['where'] = $this->preprocessWhere($sanitizedQuery['where']); + $sanitizedQuery['changes'] = $this->preprocessChanges($sanitizedQuery['changes']); + + // Order part of the query was defined + if (\count($sanitizedQuery['order']) > 0) { + $sanitizedQuery['order'] = $this->preprocessOrder($sanitizedQuery['order']); + } else { + unset($sanitizedQuery['order']); + } + + // No limit - remove it from options + if ($sanitizedQuery['limit'] === 0) { + unset($sanitizedQuery['limit']); + } + + $sanitizedQuery['table'] = $this->config->getTableName(); + + try { + // Execute the query + return $this->db->update($sanitizedQuery); + } catch (DBInvalidOptionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + $e->getMessage() + ); + } + } + + /** + * Build update (SET) query part of all SQL queries + * + * @param array $changes + * @return array + * + * @throws DBInvalidOptionException + */ + private function preprocessChanges(array $changes) + { + // Separate field SQL and field values + $changesProcessed = []; + + // Go through the fields + foreach ($changes as $fieldName => $fieldValue) { + // Freestyle update clause + if (\is_int($fieldName)) { + $fieldName = $fieldValue; + $fieldValue = []; + } + + // Make sure we have a valid fieldname + if (!\is_string($fieldName)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Invalid "changes" / SET definition, expression is not a string: ' . + DBDebug::sanitizeData($fieldName) + ); + } + + // No variables are contained in SQL + if (\strpos($fieldName, ':') === false) { + $fieldValue = $this->castOneTableVariable($fieldValue, $fieldName); + $fieldName = $this->convertNameToTable($fieldName); + } else { // Variables are contained in SQL + // Cast change values - can be scalar or array + $fieldValue = $this->castTableVariable($fieldValue); + + // Convert all :variable: values from object to table notation + $fieldName = $this->convertNamesToTableInString($fieldName); + + // Variables still exist which were not resolved + if (\strpos($fieldName, ':') !== false) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Unresolved colons in "changes" / SET clause: ' . + DBDebug::sanitizeData($fieldName) + ); + } + } + + // Add change entry to the processed list + if (\is_array($fieldValue) && \count($fieldValue) === 0) { + $changesProcessed[] = $fieldName; + } else { + $changesProcessed[$fieldName] = $fieldValue; + } + } + + return $changesProcessed; + } + + /** + * @inheritDoc + */ + public function insert(array $fields, bool $returnInsertId = false): ?string + { + // Insert fields with converted field names + $actualFields = []; + + // Convert all the field names from object to table + foreach ($fields as $fieldName => $fieldValue) { + $actualFields[$this->convertNameToTable($fieldName)] = $this->castOneTableVariable( + $fieldValue, + $fieldName + ); + } + + try { + // Delegate insert to DBAL + $this->db->insert($this->config->getTableName(), $actualFields); + } catch (DBInvalidOptionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + $e->getMessage() + ); + } + + // Return insert ID if requested + if ($returnInsertId === true) { + return $this->db->lastInsertId(); + } + + // Return null if no insert ID is requested + return null; + } + + /** + * @inheritDoc + */ + public function insertOrUpdate(array $fields, array $indexFields = [], array $updateFields = []): string + { + // Fields after conversion to table notation + $actualIndexFields = []; + + // Convert the index field names from object to table + foreach ($indexFields as $fieldName) { + if (!isset($fields[$fieldName])) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'Index field specified do not occur in data array: ' . + DBDebug::sanitizeData($fieldName) + ); + } + + $actualIndexFields[] = $this->convertNameToTable($fieldName); + } + + // Insert fields with converted field names + $actualFields = []; + + // Convert all the field names from object to table + foreach ($fields as $fieldName => $fieldValue) { + $actualFields[$this->convertNameToTable($fieldName)] = $this->castOneTableVariable( + $fieldValue, + $fieldName + ); + } + + // Processed update array + $actualUpdateFields = []; + + // Process the update part of the query + foreach ($updateFields as $fieldName => $fieldValue) { + // Freestyle update clause - make the object-to-table notation conversion + if (\is_int($fieldName)) { + $actualUpdateFields[] = $this->convertNamesToTableInString($fieldValue); + continue; + } + + // Structured update clause - convert table name and cast the value + $actualUpdateFields[$this->convertNameToTable($fieldName)] = $this->castOneTableVariable( + $fieldValue, + $fieldName + ); + } + + try { + // Call the upsert function with adjusted values + $result = $this->db->upsert( + $this->config->getTableName(), + $actualFields, + $actualIndexFields, + $actualUpdateFields + ); + } catch (DBInvalidOptionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + $e->getMessage() + ); + } + + // Return the information on whether the row was updated or inserted + switch (\intval($result)) { + case 1: + return 'insert'; + case 2: + return 'update'; + } + + // Row was not changed + return ''; + } + + /** + * @inheritDoc + */ + public function delete(array $where): int + { + // We need specific WHERE restrictions, otherwise there is a huge risk + // of overwriting to many entries + if (\count($where) === 0) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + 'No restricting "where" arguments defined for DELETE' + ); + } + + // Generate the WHERE part of the query + $where = $this->preprocessWhere($where); + + try { + // Execute the query + return $this->db->delete($this->config->getTableName(), $where); + } catch (DBInvalidOptionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [RepositoryReadOnlyInterface::class, ActionInterface::class], + $e->getMessage() + ); + } + } +} diff --git a/src/RepositoryWriteableInterface.php b/src/RepositoryWriteableInterface.php new file mode 100644 index 0000000..141b180 --- /dev/null +++ b/src/RepositoryWriteableInterface.php @@ -0,0 +1,51 @@ +db = $db; + } + + /** + * Create transaction with given repositories, making sure they all use the same database connection + * + * @param RepositoryReadOnlyInterface[]|RepositoryBuilderReadOnlyInterface[] $repositories + * @return Transaction + * + * @throws DBException Common minimal exception thrown if anything goes wrong + */ + public static function withRepositories(array $repositories): self + { + /** + * Connection to use for transaction + * + * @var DBInterface|null $connection + */ + $connection = null; + + // Go through all repositories + foreach ($repositories as $repository) { + // Builder repository found - get the base repository from it + if ($repository instanceof RepositoryBuilderReadOnlyInterface) { + try { + $builderRepositoryReflection = new \ReflectionClass($repository); + $builderRepositoryPropertyReflection = $builderRepositoryReflection->getProperty('repository'); + $builderRepositoryPropertyReflection->setAccessible(true); + $repository = $builderRepositoryPropertyReflection->getValue($repository); + } catch (\ReflectionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [Transaction::class], + 'Base repository not found in builder repository via reflection. ' . + 'Make sure you use officially supported classes' + ); + } + } + + // Base repository found - get the DBInterface from it + if ($repository instanceof RepositoryReadOnlyInterface) { + try { + $baseRepositoryReflection = new \ReflectionClass($repository); + $baseRepositoryPropertyReflection = $baseRepositoryReflection->getProperty('db'); + $baseRepositoryPropertyReflection->setAccessible(true); + $foundConnection = $baseRepositoryPropertyReflection->getValue($repository); + } catch (\ReflectionException $e) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [Transaction::class], + 'Connection not found in base repository via reflection. ' . + 'Make sure you use officially supported classes' + ); + } + + // Make sure all repositories are using the same connection, otherwise a transaction is impossible + if (isset($connection) && $connection !== $foundConnection) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [TransactionInterface::class], + 'Repositories have different database connections, transaction is not possible' + ); + } + + $connection = $foundConnection; + } else { // No base repository - meaning this class is invalid + throw DBDebug::createException( + DBInvalidOptionException::class, + [TransactionInterface::class], + 'Invalid class specified to create transaction (not a repository)' + ); + } + } + + // No connection found, meaning no repositories were defined in arguments + if (!isset($connection)) { + throw DBDebug::createException( + DBInvalidOptionException::class, + [TransactionInterface::class], + 'No repositories for transaction defined' + ); + } + + return new self($connection); + } + + /** + * @inheritDoc + */ + public function run(callable $func, ...$arguments) + { + return $this->db->transaction($func, ...$arguments); + } +} diff --git a/src/TransactionInterface.php b/src/TransactionInterface.php new file mode 100644 index 0000000..4429db8 --- /dev/null +++ b/src/TransactionInterface.php @@ -0,0 +1,19 @@ +assertEquals(new MultiSelectEntries($multiRepository), $multiRepositoryBuilder->select()); + } + + public function testSelectFreeform() + { + $multiRepository = \Mockery::mock(MultiRepositoryReadOnlyInterface::class); + + $multiRepositoryBuilder = new MultiRepositoryBuilderReadOnly($multiRepository); + + $this->assertEquals( + new MultiSelectEntriesFreeform($multiRepository), + $multiRepositoryBuilder->selectFreeform() + ); + } + + public function testCount() + { + $multiRepository = \Mockery::mock(MultiRepositoryReadOnlyInterface::class); + + $multiRepositoryBuilder = new MultiRepositoryBuilderReadOnly($multiRepository); + + $this->assertEquals(new MultiCountEntries($multiRepository), $multiRepositoryBuilder->count()); + } +} diff --git a/tests/MultiRepositoryBuilderWriteableTest.php b/tests/MultiRepositoryBuilderWriteableTest.php new file mode 100644 index 0000000..131b1ff --- /dev/null +++ b/tests/MultiRepositoryBuilderWriteableTest.php @@ -0,0 +1,32 @@ +assertEquals(new MultiUpdateEntries($multiRepository), $multiRepositoryBuilder->update()); + } + + public function testUpdateFreeform() + { + $multiRepository = \Mockery::mock(MultiRepositoryWriteableInterface::class); + + $multiRepositoryBuilder = new MultiRepositoryBuilderWriteable($multiRepository); + + $this->assertEquals( + new MultiUpdateEntriesFreeform($multiRepository), + $multiRepositoryBuilder->updateFreeform() + ); + } +} diff --git a/tests/MultiRepositoryReadOnlyTest.php b/tests/MultiRepositoryReadOnlyTest.php new file mode 100644 index 0000000..872e2eb --- /dev/null +++ b/tests/MultiRepositoryReadOnlyTest.php @@ -0,0 +1,1969 @@ +db = \Mockery::mock(DBInterfaceForTests::class)->makePartial(); + + // Initialize query handler so it can be used + $this->queryHandler = new MultiRepositoryReadOnly(); + + $this->ticketRepositoryConfig = new RepositoryConfig('', 'databasename.tickets', [ + 'ticket_id' => 'ticketId', + 'ticket_title' => 'title', + 'ticket_floaty' => 'floaty', + 'ticket_open' => 'open', + 'ticket_status' => 'status', + 'msgNumber' => 'messagesNumber', + 'last_update' => 'lastUpdate', + 'create_date' => 'createDate', + ], [ + 'ticketId' => 'ticket_id', + 'title' => 'ticket_title', + 'floaty' => 'ticket_floaty', + 'open' => 'ticket_open', + 'status' => 'ticket_status', + 'messagesNumber' => 'msgNumber', + 'lastUpdate' => 'last_update', + 'createDate' => 'create_date', + ], 'ObjectClass', [ + 'ticketId' => 'int', + 'title' => 'string', + 'floaty' => 'float', + 'open' => 'bool', + 'status' => 'int', + 'messagesNumber' => 'int', + 'lastUpdate' => 'int', + 'createDate' => 'int', + ], [ + 'ticketId' => false, + 'title' => false, + 'floaty' => false, + 'open' => false, + 'status' => false, + 'messagesNumber' => false, + 'lastUpdate' => true, + 'createDate' => false, + ]); + + $this->ticketRepository = new TestClasses\TicketRepositoryBuilderReadOnly( + new RepositoryReadOnly($this->db, $this->ticketRepositoryConfig) + ); + + $this->ticketMessageRepositoryConfig = new RepositoryConfig('', 'tickets_messages', [ + 'msg_id' => 'messageId', + 'ticket_id' => 'ticketId', + 'email_id' => 'emailId', + 'sender_type' => 'senderType', + 'create_date' => 'createDate', + ], [ + 'messageId' => 'msg_id', + 'ticketId' => 'ticket_id', + 'emailId' => 'email_id', + 'senderType' => 'sender_type', + 'createDate' => 'create_date', + ], 'ObjectClass', [ + 'messageId' => 'int', + 'ticketId' => 'int', + 'emailId' => 'int', + 'senderType' => 'string', + 'createDate' => 'int', + ], [ + 'messageId' => false, + 'ticketId' => false, + 'emailId' => false, + 'senderType' => false, + 'createDate' => false, + ]); + + $this->ticketMessageRepository = new RepositoryReadOnly($this->db, $this->ticketMessageRepositoryConfig); + + $this->emailRepository = new RepositoryReadOnly($this->db, new RepositoryConfig( + '', + 'db74.emails', + [ + 'email_id' => 'emailId', + 'to_address' => 'to', + 'from_address' => 'from', + 'automatic' => 'automatic', + 'create_date' => 'createDate', + ], + [ + 'emailId' => 'email_id', + 'to' => 'to_address', + 'from' => 'from_address', + 'automatic' => 'automatic', + 'createDate' => 'create_date', + ], + 'ObjectClass', + [ + 'emailId' => 'int', + 'to' => 'string', + 'from' => 'string', + 'automatic' => 'bool', + 'createDate' => 'int', + ], + [ + 'emailId' => false, + 'to' => false, + 'from' => false, + 'automatic' => false, + 'createDate' => false, + ] + )); + + // Complicated query as a template to test + $this->complicatedQuery = [ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ], + 'fields' => [ + 'ticket.ticketId', + 'ticket.floaty', + 'emailTo' => 'email.to', + 'ticket.open', + 'ticketTitle' => 'ticket.title', + 'ticketTitleLength' => 'LENGTH(:ticket.title:)', + 'updateMinusCreated' => ':ticket.lastUpdate:-:ticket.createDate:', + 'floaty' => ':ticket.floaty:', + 'booleany' => ':ticket.open:', + 'updateCreatedConcat' => 'CONCAT(:ticket.lastUpdate:,:ticket.createDate:)', + ], + 'tables' => [ + 'ticket', + ':message: LEFT JOIN :email: ' . + 'ON (:message.emailId: = :email.emailId: AND :email.automatic: = ?)' => true, + ':message: LEFT JOIN :email: ON (:message.emailId: = :email.emailId:)', + ], + 'where' => [ + ':ticket.ticketId: = :message.ticketId:', + 'email.to' => 'info@dada.com', + ':ticket.open: = ?' => true, + ':ticket.floaty: BETWEEN ? AND ?' => [5.5, 9.5], + ], + 'group' => [ + 'ticket.ticketId', + 'email.to', + ], + 'order' => [ + 'ticket.ticketId' => 'DESC', + 'updateMinusCreated', + '(:ticket.lastUpdate:-:ticket.createDate:)' => 'DESC', + ':ticket.lastUpdate:+:ticket.createDate:' => 'ASC', + ], + 'limit' => 30, + 'offset' => 7, + ]; + + // Freeform query parts + $this->queryFreeform = [ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ], + 'fields' => [ + 'ticket.ticketId', + ], + 'query' => ':ticket:,:message: LEFT JOIN :email: ' . + 'ON (:message.emailId: = :email.emailId: AND :email.automatic: = ?) ' . + 'WHERE (:ticket.ticketId: = :message.ticketId:) AND (:ticket.open: = ?) ' . + 'AND (:ticket.floaty: = ?) ' . + 'GROUP BY :ticket.ticketId: ' . + 'ORDER BY :ticket.ticketId: DESC,' . + 'updateMinusCreated ASC,' . + '(:ticket.lastUpdate:-:ticket.createDate:) DESC ' . + 'LIMIT 30', + 'parameters' => [ + true, + true, + 9.5, + ], + ]; + } + + public function testMinimal() + { + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'ticket.ticketId' => 'ticket.ticket_id', + 'ticket.floaty' => 'ticket.ticket_floaty', + 'ticket.open' => 'ticket.ticket_open', + 'ticket.title' => 'ticket.ticket_title', + 'ticket.lastUpdate' => 'ticket.last_update', + ], + 'tables' => [ + 'databasename.tickets ticket', + 'tickets_messages message', + 'db74.emails email', + ], + 'where' => [ + 'ticket.ticket_id' => [77, 88, 193], + 'ticket.ticket_open' => 1, + ], + ]; + + // What the database returns + $resultsFromDb = [ + [ + 'ticket.ticketId' => '54', + 'ticket.floaty' => '0.3', + 'ticket.open' => '1', + 'ticket.title' => 'Dadaism', + 'ticket.lastUpdate' => null, + ], + ]; + + // After the data was processed according to types + $resultsProcessed = [ + [ + 'ticket.ticketId' => 54, + 'ticket.floaty' => 0.3, + 'ticket.open' => true, + 'ticket.title' => 'Dadaism', + 'ticket.lastUpdate' => null, + ], + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andReturn($resultsFromDb); + + // Attempt select + $results = $this->queryHandler->select([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ], + 'fields' => [ + 'ticket.ticketId', + 'ticket.floaty', + 'ticket.open', + 'ticket.title', + 'ticket.lastUpdate', + ], + 'where' => [ + 'ticket.ticketId' => [77, 88, 193], + 'ticket.open' => true, + ], + ]); + + // Make sure we received the correct sanitized results + $this->assertSame($resultsProcessed, $results); + } + + public function testSelectOne() + { + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'ticket.ticketId' => 'ticket.ticket_id', + 'ticket.floaty' => 'ticket.ticket_floaty', + 'ticket.open' => 'ticket.ticket_open', + 'ticket.title' => 'ticket.ticket_title', + 'ticket.lastUpdate' => 'ticket.last_update', + ], + 'tables' => [ + 'databasename.tickets ticket', + 'tickets_messages message', + 'db74.emails email', + ], + 'where' => [ + 'ticket.ticket_id' => [77, 88, 193], + 'ticket.ticket_open' => 1, + ], + 'limit' => 1, + ]; + + // What the database returns + $resultsFromDb = [ + [ + 'ticket.ticketId' => '54', + 'ticket.floaty' => '0.3', + 'ticket.open' => '1', + 'ticket.title' => 'Dadaism', + 'ticket.lastUpdate' => null, + ], + ]; + + // After the data was processed according to types + $resultsProcessed = [ + 'ticket.ticketId' => 54, + 'ticket.floaty' => 0.3, + 'ticket.open' => true, + 'ticket.title' => 'Dadaism', + 'ticket.lastUpdate' => null, + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andReturn($resultsFromDb); + + // Attempt select + $results = $this->queryHandler->selectOne([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ], + 'fields' => [ + 'ticket.ticketId', + 'ticket.floaty', + 'ticket.open', + 'ticket.title', + 'ticket.lastUpdate', + ], + 'where' => [ + 'ticket.ticketId' => [77, 88, 193], + 'ticket.open' => true, + ], + ]); + + // Make sure we received the correct sanitized results + $this->assertSame($resultsProcessed, $results); + } + + public function testFlattenedField() + { + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'ticket.ticketId' => 'ticket.ticket_id', + ], + 'tables' => [ + 'databasename.tickets ticket', + 'tickets_messages message', + 'db74.emails email', + ], + 'where' => [ + 'ticket.ticket_id' => 77, + 'ticket.ticket_open' => 0, + ], + 'limit' => 3, + 'lock' => true, + ]; + + // What the database returns + $resultsFromDb = [ + [ + 'ticket.ticketId' => '54', + ], + [ + 'ticket.ticketId' => '33', + ], + [ + 'ticket.ticketId' => '89', + ], + ]; + + // After the data was processed according to types + $resultsProcessed = [ + 54, + 33, + 89, + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andReturn($resultsFromDb); + + // Attempt select + $results = $this->queryHandler->selectFlattenedFields([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ], + 'fields' => [ + 'ticket.ticketId', + ], + 'where' => [ + 'ticket.ticketId' => '77', + 'ticket.open' => false, + ], + 'limit' => 3, + 'lock' => true, + ]); + + // Make sure we received the correct sanitized results + $this->assertSame($resultsProcessed, $results); + } + + public function testFlattenedFields() + { + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'ticket.ticketId' => 'ticket.ticket_id', + 'ticket.open' => 'ticket.ticket_open', + ], + 'tables' => [ + 'databasename.tickets ticket', + 'tickets_messages message', + 'db74.emails email', + ], + 'where' => [ + 'ticket.ticket_id' => 77, + 'ticket.ticket_open' => 0, + ], + 'limit' => 3, + 'lock' => true, + ]; + + // What the database returns + $resultsFromDb = [ + [ + 'ticket.ticketId' => '54', + 'ticket.open' => '1', + ], + [ + 'ticket.ticketId' => '33', + 'ticket.open' => '0', + ], + [ + 'ticket.ticketId' => '89', + 'ticket.open' => '1', + ], + ]; + + // After the data was processed according to types + $resultsProcessed = [ + 54, + true, + 33, + false, + 89, + true, + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andReturn($resultsFromDb); + + // Attempt select + $results = $this->queryHandler->selectFlattenedFields([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ], + 'fields' => [ + 'ticket.ticketId', + 'ticket.open', + ], + 'where' => [ + 'ticket.ticketId' => '77', + 'ticket.open' => false, + ], + 'limit' => 3, + 'lock' => true, + ]); + + // Make sure we received the correct sanitized results + $this->assertSame($resultsProcessed, $results); + } + + public function testFlattenedFieldLegacy() + { + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'ticket.ticketId' => 'ticket.ticket_id', + ], + 'tables' => [ + 'databasename.tickets ticket', + 'tickets_messages message', + 'db74.emails email', + ], + 'where' => [ + 'ticket.ticket_id' => 77, + 'ticket.ticket_open' => 0, + ], + 'limit' => 3, + 'lock' => true, + ]; + + // What the database returns + $resultsFromDb = [ + [ + 'ticket.ticketId' => '54', + ], + [ + 'ticket.ticketId' => '33', + ], + [ + 'ticket.ticketId' => '89', + ], + ]; + + // After the data was processed according to types + $resultsProcessed = [ + 54, + 33, + 89, + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andReturn($resultsFromDb); + + // Attempt select + $results = $this->queryHandler->select([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ], + 'fields' => [ + 'ticket.ticketId', + ], + 'where' => [ + 'ticket.ticketId' => '77', + 'ticket.open' => false, + ], + 'limit' => 3, + 'flattenFields' => true, + 'lock' => true, + ]); + + // Make sure we received the correct sanitized results + $this->assertSame($resultsProcessed, $results); + } + + public function testFlattenedFieldsLegacy() + { + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'ticket.ticketId' => 'ticket.ticket_id', + 'ticket.open' => 'ticket.ticket_open', + ], + 'tables' => [ + 'databasename.tickets ticket', + 'tickets_messages message', + 'db74.emails email', + ], + 'where' => [ + 'ticket.ticket_id' => 77, + 'ticket.ticket_open' => 0, + ], + 'limit' => 3, + 'lock' => true, + ]; + + // What the database returns + $resultsFromDb = [ + [ + 'ticket.ticketId' => '54', + 'ticket.open' => '1', + ], + [ + 'ticket.ticketId' => '33', + 'ticket.open' => '0', + ], + [ + 'ticket.ticketId' => '89', + 'ticket.open' => '1', + ], + ]; + + // After the data was processed according to types + $resultsProcessed = [ + 54, + true, + 33, + false, + 89, + true, + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andReturn($resultsFromDb); + + // Attempt select + $results = $this->queryHandler->select([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ], + 'fields' => [ + 'ticket.ticketId', + 'ticket.open', + ], + 'where' => [ + 'ticket.ticketId' => '77', + 'ticket.open' => false, + ], + 'limit' => 3, + 'flattenFields' => true, + 'lock' => true, + ]); + + // Make sure we received the correct sanitized results + $this->assertSame($resultsProcessed, $results); + } + + public function testCountQuery() + { + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'COUNT(*) AS "count"', + ], + 'tables' => [ + 'databasename.tickets ticket', + ], + 'where' => [ + 'ticket.ticket_id' => 77, + 'ticket.ticket_open' => 0, + ], + 'lock' => true, + ]; + + // What the database returns + $resultsFromDb = [ + [ + 'count' => '54', + ], + ]; + + // After the data was processed according to types + $resultsProcessed = [ + 54, + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andReturn($resultsFromDb); + + // Attempt select + $results = $this->queryHandler->select([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + ], + 'fields' => [ + 'count' => 'COUNT(*)', + ], + 'where' => [ + 'ticket.ticketId' => '77', + 'ticket.open' => false, + ], + 'flattenFields' => true, + 'lock' => true, + ]); + + // Make sure we received the correct sanitized results + $this->assertSame($resultsProcessed, $results); + } + + public function testComplicatedQuery() + { + // Default database results + $dbResults = [ + [ + 'ticket.ticketId' => '5', + 'ticket.floaty' => '3.78', + 'emailTo' => 'test@example.com', + 'ticket.open' => '1', + 'ticketTitle' => 'First ticket', + 'ticketTitleLength' => '8', + 'updateMinusCreated' => '5', + 'floaty' => '9.5', + 'booleany' => '1', + 'updateCreatedConcat' => '5', + ], + [ + 'ticket.ticketId' => '53', + 'ticket.floaty' => '8.3', + 'emailTo' => 'test55@example.com', + 'ticket.open' => '0', + 'ticketTitle' => 'Second ticket', + 'ticketTitleLength' => '9', + 'updateMinusCreated' => '8', + 'floaty' => '7', + 'booleany' => '0', + 'updateCreatedConcat' => '5', + ], + [ + 'ticket.ticketId' => '193', + 'ticket.floaty' => '11', + 'emailTo' => 'test@muster.de', + 'ticket.open' => '1', + 'ticketTitle' => 'Third ticket', + 'ticketTitleLength' => '10', + 'updateMinusCreated' => '53', + 'floaty' => '3.3', + 'booleany' => '1', + 'updateCreatedConcat' => '5', + ], + ]; + + // The processed database results + $dbResultsSanitized = [ + [ + 'ticket.ticketId' => 5, + 'ticket.floaty' => 3.78, + 'emailTo' => 'test@example.com', + 'ticket.open' => true, + 'ticketTitle' => 'First ticket', + 'ticketTitleLength' => '8', + 'updateMinusCreated' => 5, + 'floaty' => 9.5, + 'booleany' => true, + 'updateCreatedConcat' => '5', + ], + [ + 'ticket.ticketId' => 53, + 'ticket.floaty' => 8.3, + 'emailTo' => 'test55@example.com', + 'ticket.open' => false, + 'ticketTitle' => 'Second ticket', + 'ticketTitleLength' => '9', + 'updateMinusCreated' => 8, + 'floaty' => 7.0, + 'booleany' => false, + 'updateCreatedConcat' => '5', + ], + [ + 'ticket.ticketId' => 193, + 'ticket.floaty' => 11.0, + 'emailTo' => 'test@muster.de', + 'ticket.open' => true, + 'ticketTitle' => 'Third ticket', + 'ticketTitleLength' => '10', + 'updateMinusCreated' => 53, + 'floaty' => 3.3, + 'booleany' => true, + 'updateCreatedConcat' => '5', + ], + ]; + + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'ticket.ticketId' => 'ticket.ticket_id', + 'ticket.floaty' => 'ticket.ticket_floaty', + 'emailTo' => 'email.to_address', + 'ticket.open' => 'ticket.ticket_open', + 'ticketTitle' => 'ticket.ticket_title', + '(LENGTH(' . $this->db->quoteIdentifier('ticket.ticket_title') . ')) AS ' . + $this->db->quoteIdentifier('ticketTitleLength'), + '(' . $this->db->quoteIdentifier('ticket.last_update') . '-' . + $this->db->quoteIdentifier('ticket.create_date') . ') AS ' . + $this->db->quoteIdentifier('updateMinusCreated'), + '(' . $this->db->quoteIdentifier('ticket.ticket_floaty') . ') AS ' . + $this->db->quoteIdentifier('floaty'), + '(' . $this->db->quoteIdentifier('ticket.ticket_open') . ') AS ' . + $this->db->quoteIdentifier('booleany'), + '(CONCAT(' . $this->db->quoteIdentifier('ticket.last_update') . ',' . + $this->db->quoteIdentifier('ticket.create_date') . ')) AS ' . + $this->db->quoteIdentifier('updateCreatedConcat'), + ], + 'tables' => [ + 'databasename.tickets ticket', + $this->db->quoteIdentifier('tickets_messages') . ' ' . $this->db->quoteIdentifier('message') . + ' LEFT JOIN ' . $this->db->quoteIdentifier('db74.emails') . ' ' . $this->db->quoteIdentifier('email') . + ' ON (' . $this->db->quoteIdentifier('message.email_id') . ' = ' . + $this->db->quoteIdentifier('email.email_id') . ' AND ' . + $this->db->quoteIdentifier('email.automatic') . ' = ?)' => true, + $this->db->quoteIdentifier('tickets_messages') . ' ' . $this->db->quoteIdentifier('message') . + ' LEFT JOIN ' . $this->db->quoteIdentifier('db74.emails') . ' ' . $this->db->quoteIdentifier('email') . + ' ON (' . $this->db->quoteIdentifier('message.email_id') . ' = ' . + $this->db->quoteIdentifier('email.email_id') . ')', + ], + 'where' => [ + $this->db->quoteIdentifier('ticket.ticket_id') . ' = ' . + $this->db->quoteIdentifier('message.ticket_id'), + 'email.to_address' => 'info@dada.com', + $this->db->quoteIdentifier('ticket.ticket_open') . ' = ?' => 1, + $this->db->quoteIdentifier('ticket.ticket_floaty') . ' BETWEEN ? AND ?' => [5.5, 9.5], + ], + 'group' => [ + 'ticket.ticket_id', + 'email.to_address', + ], + 'order' => [ + 'ticket.ticket_id' => 'DESC', + 'updateMinusCreated', + '(:ticket.last_update:-:ticket.create_date:)' => 'DESC', + ':ticket.last_update:+:ticket.create_date:' => 'ASC', + ], + 'limit' => 30, + 'offset' => 7, + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andReturn($dbResults); + + // Attempt select + $results = $this->queryHandler->select($this->complicatedQuery); + + // Make sure we received the correct sanitized results + $this->assertSame($dbResultsSanitized, $results); + } + + public function testFreeform() + { + // Default database results + $dbResults = [ + [ + 'ticket.ticketId' => '5', + 'ticket.floaty' => '3.78', + 'emailTo' => 'test@example.com', + 'ticket.open' => '1', + 'ticketTitle' => 'First ticket', + 'updateMinusCreated' => '5', + 'floaty' => '9.5', + 'booleany' => '1', + 'updateCreatedConcat' => '5', + ], + [ + 'ticket.ticketId' => '53', + 'ticket.floaty' => '8.3', + 'emailTo' => 'test55@example.com', + 'ticket.open' => '0', + 'ticketTitle' => 'Second ticket', + 'updateMinusCreated' => '8', + 'floaty' => '7', + 'booleany' => '0', + 'updateCreatedConcat' => '5', + ], + [ + 'ticket.ticketId' => '193', + 'ticket.floaty' => '11', + 'emailTo' => 'test@muster.de', + 'ticket.open' => '1', + 'ticketTitle' => 'Third ticket', + 'updateMinusCreated' => '53', + 'floaty' => '3.3', + 'booleany' => '1', + 'updateCreatedConcat' => '5', + ], + ]; + + // The processed database results + $dbResultsSanitized = [ + [ + 'ticket.ticketId' => 5, + 'ticket.floaty' => 3.78, + 'emailTo' => 'test@example.com', + 'ticket.open' => true, + 'ticketTitle' => 'First ticket', + 'updateMinusCreated' => 5, + 'floaty' => 9.5, + 'booleany' => true, + 'updateCreatedConcat' => '5', + ], + [ + 'ticket.ticketId' => 53, + 'ticket.floaty' => 8.3, + 'emailTo' => 'test55@example.com', + 'ticket.open' => false, + 'ticketTitle' => 'Second ticket', + 'updateMinusCreated' => 8, + 'floaty' => 7.0, + 'booleany' => false, + 'updateCreatedConcat' => '5', + ], + [ + 'ticket.ticketId' => 193, + 'ticket.floaty' => 11.0, + 'emailTo' => 'test@muster.de', + 'ticket.open' => true, + 'ticketTitle' => 'Third ticket', + 'updateMinusCreated' => 53, + 'floaty' => 3.3, + 'booleany' => true, + 'updateCreatedConcat' => '5', + ], + ]; + + // Freeform query parts + $queryFreeform = [ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ], + 'fields' => [ + 'ticket.ticketId', + 'ticket.floaty', + 'emailTo' => 'email.to', + 'ticket.open', + 'ticketTitle' => 'ticket.title', + 'updateMinusCreated' => ':ticket.lastUpdate:-:ticket.createDate:', + 'floaty' => ':ticket.floaty:', + 'booleany' => ':ticket.open:', + 'updateCreatedConcat' => 'CONCAT(:ticket.lastUpdate:,:ticket.createDate:)', + ], + 'query' => ':ticket:,:message: LEFT JOIN :email: ' . + 'ON (:message.emailId: = :email.emailId: AND :email.automatic: = ?) ' . + 'WHERE (:ticket.ticketId: = :message.ticketId:) AND (:ticket.open: = ?) AND (:ticket.floaty: = ?) ' . + 'GROUP BY :ticket.ticketId: ' . + 'ORDER BY :ticket.ticketId: DESC,' . + 'updateMinusCreated ASC,' . + '(:ticket.lastUpdate:-:ticket.createDate:) DESC ' . + 'LIMIT 30', + 'parameters' => [ + true, + true, + 9.5, + ], + ]; + + // The values we want to receive + $expectedQuery = 'SELECT ' . $this->db->quoteIdentifier('ticket.ticket_id') . ' AS "ticket.ticketId",' . + $this->db->quoteIdentifier('ticket.ticket_floaty') . ' AS "ticket.floaty",' . + $this->db->quoteIdentifier('email.to_address') . ' AS "emailTo",' . + $this->db->quoteIdentifier('ticket.ticket_open') . ' AS "ticket.open",' . + $this->db->quoteIdentifier('ticket.ticket_title') . ' AS "ticketTitle",' . + '(' . $this->db->quoteIdentifier('ticket.last_update') . '-' . + $this->db->quoteIdentifier('ticket.create_date') . ') AS "updateMinusCreated",' . + '(' . $this->db->quoteIdentifier('ticket.ticket_floaty') . ') AS "floaty",' . + '(' . $this->db->quoteIdentifier('ticket.ticket_open') . ') AS "booleany",' . + '(CONCAT(' . $this->db->quoteIdentifier('ticket.last_update') . ',' . + $this->db->quoteIdentifier('ticket.create_date') . ')) AS "updateCreatedConcat" ' . + 'FROM ' . $this->db->quoteIdentifier('databasename.tickets') . ' ' . + $this->db->quoteIdentifier('ticket') . ',' . + $this->db->quoteIdentifier('tickets_messages') . ' ' . $this->db->quoteIdentifier('message') . + ' LEFT JOIN ' . $this->db->quoteIdentifier('db74.emails') . ' ' . $this->db->quoteIdentifier('email') . + ' ON (' . $this->db->quoteIdentifier('message.email_id') . ' = ' . + $this->db->quoteIdentifier('email.email_id') . ' AND ' . + $this->db->quoteIdentifier('email.automatic') . ' = ?) ' . + 'WHERE (' . $this->db->quoteIdentifier('ticket.ticket_id') . ' = ' . + $this->db->quoteIdentifier('message.ticket_id') . ') ' . + 'AND (' . $this->db->quoteIdentifier('ticket.ticket_open') . ' = ?) ' . + 'AND (' . $this->db->quoteIdentifier('ticket.ticket_floaty') . ' = ?) ' . + 'GROUP BY ' . $this->db->quoteIdentifier('ticket.ticket_id') . ' ' . + 'ORDER BY ' . $this->db->quoteIdentifier('ticket.ticket_id') . ' DESC,' . + 'updateMinusCreated ASC,' . + '(' . $this->db->quoteIdentifier('ticket.last_update') . '-' . + $this->db->quoteIdentifier('ticket.create_date') . ') DESC ' . + 'LIMIT 30'; + $values = [1, 1, 9.5]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with(\Mockery::mustBe($expectedQuery), \Mockery::mustBe($values)) + ->andReturn($dbResults); + + // Attempt select + $results = $this->queryHandler->select($queryFreeform); + + // Make sure we received the correct sanitized results + $this->assertSame($dbResultsSanitized, $results); + } + + public function testFreeformOneField() + { + // Default database results + $dbResults = [ + [ + 'ticket.ticketId' => '54', + ], + [ + 'ticket.ticketId' => '33', + ], + [ + 'ticket.ticketId' => '89', + ], + ]; + + // The processed database results + $dbResultsSanitized = [ + 54, + 33, + 89, + ]; + + // The values we want to receive + $expectedQuery = 'SELECT ' . $this->db->quoteIdentifier('ticket.ticket_id') . ' AS "ticket.ticketId" ' . + 'FROM ' . $this->db->quoteIdentifier('databasename.tickets') . ' ' . $this->db->quoteIdentifier('ticket') . + ',' . $this->db->quoteIdentifier('tickets_messages') . ' ' . $this->db->quoteIdentifier('message') . + ' LEFT JOIN ' . $this->db->quoteIdentifier('db74.emails') . ' ' . $this->db->quoteIdentifier('email') . + ' ON (' . $this->db->quoteIdentifier('message.email_id') . ' = ' . + $this->db->quoteIdentifier('email.email_id') . + ' AND ' . $this->db->quoteIdentifier('email.automatic') . ' = ?) ' . + 'WHERE (' . $this->db->quoteIdentifier('ticket.ticket_id') . ' = ' . + $this->db->quoteIdentifier('message.ticket_id') . ') AND (' . + $this->db->quoteIdentifier('ticket.ticket_open') . ' = ?) ' . + 'AND (' . $this->db->quoteIdentifier('ticket.ticket_floaty') . ' = ?) ' . + 'GROUP BY ' . $this->db->quoteIdentifier('ticket.ticket_id') . ' ' . + 'ORDER BY ' . $this->db->quoteIdentifier('ticket.ticket_id') . ' DESC,' . + 'updateMinusCreated ASC,' . + '(' . $this->db->quoteIdentifier('ticket.last_update') . '-' . + $this->db->quoteIdentifier('ticket.create_date') . ') DESC ' . + 'LIMIT 30'; + $values = [1, 1, 9.5]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with(\Mockery::mustBe($expectedQuery), \Mockery::mustBe($values)) + ->andReturn($dbResults); + + // Attempt select + $results = $this->queryHandler->selectFlattenedFields($this->queryFreeform); + + // Make sure we received the correct sanitized results + $this->assertSame($dbResultsSanitized, $results); + } + + public function testUnrecognizedOption() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid WHERE value + $this->complicatedQuery['unrecognized'] = [ + 'ticket.ticketId' => 5, + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidFieldsValue() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid fields value + $this->complicatedQuery['fields'] = 'ticket'; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidWhere1Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid WHERE value + $this->complicatedQuery['where'] = [ + 'ticket.ticketIdUndefined' => 5, + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidWhere2Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid WHERE value + $this->complicatedQuery['where'] = [ + ':ticket.ticketIdUndefined: = :message.ticketId:', + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidWhere3Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid WHERE value + $this->complicatedQuery['where'] = [ + 1 => 5, + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidWhere4Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid WHERE value + $this->complicatedQuery['where'] = []; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidRepositories1Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid repositories definition + $this->complicatedQuery['repositories'] = []; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidRepositories2Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SELECT value + $this->complicatedQuery['repositories'] = [ + 5, + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidRepositories3Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SELECT value + $this->complicatedQuery['repositories']['email'] = new \stdClass(); + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidFields1Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SELECT value + $this->complicatedQuery['fields'] = []; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidFields2Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SELECT value + $this->complicatedQuery['fields'] = [ + 'ticket.ticketIdInvalid', + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidFields3Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SELECT value + $this->complicatedQuery['fields'] = [ + 5, + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidFields4Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SELECT value + $this->complicatedQuery['fields'] = [ + 'email' => 5, + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidFields5Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SELECT value + $this->complicatedQuery['fields'] = [ + ':email' => 'ticket.ticketId', + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidFields6Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SELECT value + $this->complicatedQuery['fields'] = [ + 'email' => ':ticket.ticketIdInvalid:', + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidTables1Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid FROM value + $this->complicatedQuery['tables'] = [ + 'invalidTable', + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidTables2Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid FROM value + $this->complicatedQuery['tables'] = [ + ':invalidTableExpression:', + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidTables3Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid FROM value + $this->complicatedQuery['tables'] = [ + 0, + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidGroup1Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid GROUP value + $this->complicatedQuery['group'] = [ + 'ticket.ticketIdInvalid', + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidGroup2Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid GROUP value + $this->complicatedQuery['group'] = [ + new \stdClass(), + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidGroup3Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid GROUP value + $this->complicatedQuery['group'] = [ + ':ticket.ticketId:', + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidOrder1Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid ORDER value + $this->complicatedQuery['order'] = [ + ':ticket.ticketIdInvalid:', + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidOrder2Value() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid ORDER value + $this->complicatedQuery['order'] = [ + new \stdClass(), + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidFlattenFieldsValue() + { + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid ORDER value + $this->complicatedQuery['flattenFields'] = 5; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testInvalidRepositoryFieldType() + { + $this->expectException(DBInvalidOptionException::class); + + // Ticket repository - mocked + $ticketRepositoryConfig = new RepositoryConfig('', 'databasename.tickets', [ + 'ticket_id' => 'ticketId', + 'ticket_title' => 'title', + 'ticket_floaty' => 'floaty', + 'ticket_open' => 'open', + 'ticket_status' => 'status', + 'msgNumber' => 'messagesNumber', + 'last_update' => 'lastUpdate', + 'create_date' => 'createDate', + ], [ + 'ticketId' => 'ticket_id', + 'title' => 'ticket_title', + 'floaty' => 'ticket_floaty', + 'open' => 'ticket_open', + 'status' => 'ticket_status', + 'messagesNumber' => 'msgNumber', + 'lastUpdate' => 'last_update', + 'createDate' => 'create_date', + ], 'ObjectClass', [ + 'ticketId' => 'int', + 'title' => 'fantasyType', // invalid value! + 'floaty' => 'float', + 'open' => 'bool', + 'status' => 'int', + 'messagesNumber' => 'int', + 'lastUpdate' => 'int', + 'createDate' => 'int', + ], [ + 'ticketId' => false, + 'title' => false, + 'floaty' => false, + 'open' => false, + 'status' => false, + 'messagesNumber' => false, + 'lastUpdate' => true, + 'createDate' => false, + ]); + + $ticketRepository = new TestClasses\TicketRepositoryBuilderReadOnly( + new RepositoryReadOnly($this->db, $ticketRepositoryConfig) + ); + + $this->complicatedQuery['repositories'] = [ + 'ticket' => $ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ]; + + // Default database results + $dbResults = [ + [ + 'ticket.ticketId' => '5', + 'ticket.floaty' => '3.78', + 'emailTo' => 'test@example.com', + 'ticket.open' => '1', + 'ticketTitle' => 'First ticket', + 'ticketTitleLength' => '8', + 'updateMinusCreated' => '5', + 'floaty' => '9.5', + 'booleany' => '1', + 'updateCreatedConcat' => '5', + ], + [ + 'ticket.ticketId' => '53', + 'ticket.floaty' => '8.3', + 'emailTo' => 'test55@example.com', + 'ticket.open' => '0', + 'ticketTitle' => 'Second ticket', + 'ticketTitleLength' => '9', + 'updateMinusCreated' => '8', + 'floaty' => '7', + 'booleany' => '0', + 'updateCreatedConcat' => '5', + ], + [ + 'ticket.ticketId' => '193', + 'ticket.floaty' => '11', + 'emailTo' => 'test@muster.de', + 'ticket.open' => '1', + 'ticketTitle' => 'Third ticket', + 'ticketTitleLength' => '10', + 'updateMinusCreated' => '53', + 'floaty' => '3.3', + 'booleany' => '1', + 'updateCreatedConcat' => '5', + ], + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->andReturn($dbResults); + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testBadRepositoryForReflection() + { + $this->expectException(DBInvalidOptionException::class); + + $ticketRepository = new TestClasses\TicketRepositoryReadOnlyDifferentRepositoryBuilderVariableWithin( + new RepositoryReadOnly($this->db, $this->ticketRepositoryConfig) + ); + + $this->complicatedQuery['repositories'] = [ + 'ticket' => $ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testBadRepositoryForReflection2() + { + $this->expectException(DBInvalidOptionException::class); + + $ticketMessageRepository = new TestClasses\TicketMessageRepositoryReadOnlyDifferentRepositoryVariableWithin( + $this->db, + $this->ticketMessageRepositoryConfig + ); + + $this->complicatedQuery['repositories'] = [ + 'ticket' => $this->ticketRepository, + 'message' => $ticketMessageRepository, + 'email' => $this->emailRepository, + ]; + + // Attempt select + $this->queryHandler->select($this->complicatedQuery); + } + + public function testUnresolvedFreeform() + { + $this->expectException(DBInvalidOptionException::class); + + $this->queryFreeform['query'] = $this->queryFreeform['query'] . ' :invalid:'; + + // Attempt select + $this->queryHandler->select($this->queryFreeform); + } + + public function testFreeformNoFields() + { + $this->expectException(DBInvalidOptionException::class); + + // Attempt select + $this->queryHandler->select([ + 'repositories' => $this->queryFreeform['repositories'], + 'fields' => [], + 'query' => $this->queryFreeform['query'], + 'parameters' => $this->queryFreeform['parameters'], + ]); + } + + public function testFreeformNoQuery() + { + $this->expectException(DBInvalidOptionException::class); + + unset($this->queryFreeform['query']); + + // Attempt select + $this->queryHandler->select($this->queryFreeform); + } + + public function testFreeformNoTables() + { + $this->expectException(DBInvalidOptionException::class); + + unset($this->queryFreeform['repositories']); + + // Attempt select + $this->queryHandler->select($this->queryFreeform); + } + + public function testFreeformBadParameter() + { + $this->expectException(DBInvalidOptionException::class); + + $this->queryFreeform['parameters'] = [new \stdClass()]; + + // Attempt select + $this->queryHandler->select($this->queryFreeform); + } + + public function testSelectExceptionFromDbClass() + { + $this->expectException(DBInvalidOptionException::class); + + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'ticket.ticketId' => 'ticket.ticket_id', + 'ticket.floaty' => 'ticket.ticket_floaty', + 'ticket.open' => 'ticket.ticket_open', + ], + 'tables' => [ + 'databasename.tickets ticket', + 'tickets_messages message', + 'db74.emails email', + ], + 'where' => [ + 'ticket.ticket_id' => [77, 88, 193], + 'ticket.ticket_open' => 1, + ], + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andThrow(new DBInvalidOptionException('dada', 'file', 99, 'message')); + + // Attempt select + $this->queryHandler->select([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ], + 'fields' => [ + 'ticket.ticketId', + 'ticket.floaty', + 'ticket.open', + ], + 'where' => [ + 'ticket.ticketId' => [77, 88, 193], + 'ticket.open' => true, + ], + ]); + } + + public function testFreeformOneFieldExceptionFromDbClass() + { + $this->expectException(DBInvalidOptionException::class); + + // The values we want to receive + $expectedQuery = 'SELECT ' . $this->db->quoteIdentifier('ticket.ticket_id') . ' AS "ticket.ticketId" ' . + 'FROM ' . $this->db->quoteIdentifier('databasename.tickets') . ' ' . $this->db->quoteIdentifier('ticket') . + ',' . $this->db->quoteIdentifier('tickets_messages') . ' ' . $this->db->quoteIdentifier('message') . + ' LEFT JOIN ' . $this->db->quoteIdentifier('db74.emails') . ' ' . $this->db->quoteIdentifier('email') . + ' ON (' . $this->db->quoteIdentifier('message.email_id') . ' = ' . + $this->db->quoteIdentifier('email.email_id') . ' AND ' . + $this->db->quoteIdentifier('email.automatic') . ' = ?) ' . + 'WHERE (' . $this->db->quoteIdentifier('ticket.ticket_id') . ' = ' . + $this->db->quoteIdentifier('message.ticket_id') . ') ' . + 'AND (' . $this->db->quoteIdentifier('ticket.ticket_open') . ' = ?) ' . + 'AND (' . $this->db->quoteIdentifier('ticket.ticket_floaty') . ' = ?) ' . + 'GROUP BY ' . $this->db->quoteIdentifier('ticket.ticket_id') . ' ' . + 'ORDER BY ' . $this->db->quoteIdentifier('ticket.ticket_id') . ' DESC,' . + 'updateMinusCreated ASC,' . + '(' . $this->db->quoteIdentifier('ticket.last_update') . '-' . + $this->db->quoteIdentifier('ticket.create_date') . ') DESC ' . + 'LIMIT 30'; + $values = [1, 1, 9.5]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with(\Mockery::mustBe($expectedQuery), \Mockery::mustBe($values)) + ->andThrow(new DBInvalidOptionException('dada', 'file', 99, 'message')); + + // Attempt select + $this->queryHandler->select($this->queryFreeform); + } + + public function testRepositoriesWithTheSameConnection() + { + $db2 = \Mockery::mock(DBInterfaceForTests::class)->makePartial(); + + $ticketRepository = new TestClasses\TicketRepositoryBuilderReadOnly( + new RepositoryReadOnly($this->db, $this->ticketRepositoryConfig) + ); + + $ticketRepository2 = new TestClasses\TicketRepositoryBuilderReadOnly( + new RepositoryReadOnly($this->db, $this->ticketRepositoryConfig) + ); + + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'ticket.ticketId' => 'ticket.ticket_id', + 'ticket2.ticketId' => 'ticket2.ticket_id', + ], + 'tables' => [ + 'databasename.tickets ticket', + 'databasename.tickets ticket2', + ], + 'where' => [ + $this->db->quoteIdentifier('ticket.ticket_id') . ' = ' . + $this->db->quoteIdentifier('ticket2.ticket_id'), + 'ticket.ticket_id' => 77, + ], + ]; + + // What the database returns + $resultsFromDb = [ + [ + 'ticket.ticketId' => '54', + 'ticket2.ticketId' => '54', + ], + [ + 'ticket.ticketId' => '33', + 'ticket2.ticketId' => '33', + ], + [ + 'ticket.ticketId' => '89', + 'ticket2.ticketId' => '89', + ], + ]; + + // After the data was processed according to types + $resultsProcessed = [ + [ + 'ticket.ticketId' => 54, + 'ticket2.ticketId' => 54, + ], + [ + 'ticket.ticketId' => 33, + 'ticket2.ticketId' => 33, + ], + [ + 'ticket.ticketId' => 89, + 'ticket2.ticketId' => 89, + ], + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andReturn($resultsFromDb); + + $results = $this->queryHandler->select([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'ticket2' => $ticketRepository2, + ], + 'fields' => [ + 'ticket.ticketId', + 'ticket2.ticketId', + ], + 'where' => [ + ':ticket.ticketId: = :ticket2.ticketId:', + 'ticket.ticketId' => '77', + ], + ]); + + // Make sure we received the correct sanitized results + $this->assertSame($resultsProcessed, $results); + } + + public function testBuilderRepositoriesWithDifferentConnections() + { + $this->expectException(DBInvalidOptionException::class); + + $db2 = \Mockery::mock(DBInterfaceForTests::class)->makePartial(); + + $ticketRepository = new TestClasses\TicketRepositoryBuilderReadOnly( + new RepositoryReadOnly($this->db, $this->ticketRepositoryConfig) + ); + + $ticketRepository2 = new TestClasses\TicketRepositoryBuilderReadOnly( + new RepositoryReadOnly($db2, $this->ticketRepositoryConfig) + ); + + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'ticket.ticketId' => 'ticket.ticket_id', + 'ticket2.ticketId' => 'ticket2.ticket_id', + ], + 'tables' => [ + 'databasename.tickets ticket', + 'databasename.tickets ticket2', + ], + 'where' => [ + $this->db->quoteIdentifier('ticket.ticket_id') . ' = ' . + $this->db->quoteIdentifier('ticket2.ticket_id'), + 'ticket.ticket_id' => 77, + ], + ]; + + // What the database returns + $resultsFromDb = [ + [ + 'ticket.ticketId' => '54', + 'ticket2.ticketId' => '54', + ], + [ + 'ticket.ticketId' => '33', + 'ticket2.ticketId' => '33', + ], + [ + 'ticket.ticketId' => '89', + 'ticket2.ticketId' => '89', + ], + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andReturn($resultsFromDb); + + // Attempt select + $this->queryHandler->select([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'ticket2' => $ticketRepository2, + ], + 'fields' => [ + 'ticket.ticketId', + 'ticket2.ticketId', + ], + 'where' => [ + ':ticket.ticketId: = :ticket2.ticketId:', + 'ticket.ticketId' => '77', + ], + ]); + } + + public function testBaseRepositoriesWithDifferentConnections() + { + $this->expectException(DBInvalidOptionException::class); + + $db2 = \Mockery::mock(DBInterfaceForTests::class)->makePartial(); + + $ticketRepository = new RepositoryReadOnly($this->db, $this->ticketRepositoryConfig); + + $ticketRepository2 = new RepositoryReadOnly($db2, $this->ticketRepositoryConfig); + + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'ticket.ticketId' => 'ticket.ticket_id', + 'ticket2.ticketId' => 'ticket2.ticket_id', + ], + 'tables' => [ + 'databasename.tickets ticket', + 'databasename.tickets ticket2', + ], + 'where' => [ + $this->db->quoteIdentifier('ticket.ticket_id') . ' = ' . + $this->db->quoteIdentifier('ticket2.ticket_id'), + 'ticket.ticket_id' => 77, + ], + ]; + + // What the database returns + $resultsFromDb = [ + [ + 'ticket.ticketId' => '54', + 'ticket2.ticketId' => '54', + ], + [ + 'ticket.ticketId' => '33', + 'ticket2.ticketId' => '33', + ], + [ + 'ticket.ticketId' => '89', + 'ticket2.ticketId' => '89', + ], + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andReturn($resultsFromDb); + + // Attempt select + $this->queryHandler->select([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'ticket2' => $ticketRepository2, + ], + 'fields' => [ + 'ticket.ticketId', + 'ticket2.ticketId', + ], + 'where' => [ + ':ticket.ticketId: = :ticket2.ticketId:', + 'ticket.ticketId' => '77', + ], + ]); + } + + public function testRepositoryWithInvalidConnection() + { + $this->expectException(DBInvalidOptionException::class); + + $ticketRepository = new TicketRepositoryReadOnlyCorrectNameButInvalidDatabaseConnection( + $this->ticketRepositoryConfig + ); + + // The query we want to receive + $expectedQuery = [ + 'fields' => [ + 'ticket.ticketId' => 'ticket.ticket_id', + ], + 'tables' => [ + 'databasename.tickets ticket', + ], + 'where' => [ + 'ticket.ticket_id' => 77, + ], + ]; + + // What the database returns + $resultsFromDb = [ + [ + 'ticket.ticketId' => '54', + ], + [ + 'ticket.ticketId' => '33', + ], + [ + 'ticket.ticketId' => '89', + ], + ]; + + // Fetching results - return the stored results + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($expectedQuery) + ->andReturn($resultsFromDb); + + // Attempt select + $this->queryHandler->select([ + 'repositories' => [ + 'ticket' => $ticketRepository, + ], + 'fields' => [ + 'ticket.ticketId', + ], + 'where' => [ + 'ticket.ticketId' => '77', + ], + ]); + } +} diff --git a/tests/MultiRepositoryWriteableTest.php b/tests/MultiRepositoryWriteableTest.php new file mode 100644 index 0000000..65a9bd0 --- /dev/null +++ b/tests/MultiRepositoryWriteableTest.php @@ -0,0 +1,712 @@ +db = \Mockery::mock(DBInterfaceForTests::class)->makePartial(); + + // Initialize query handler so it can be used + $this->queryHandler = new MultiRepositoryWriteable(); + + // Ticket repository - mocked + $this->ticketRepositoryConfig = new RepositoryConfig('', 'databasename.tickets', [ + 'ticket_id' => 'ticketId', + 'ticket_title' => 'title', + 'ticket_floaty' => 'floaty', + 'ticket_open' => 'open', + 'ticket_status' => 'status', + 'msgNumber' => 'messagesNumber', + 'last_update' => 'lastUpdate', + 'create_date' => 'createDate', + ], [ + 'ticketId' => 'ticket_id', + 'title' => 'ticket_title', + 'floaty' => 'ticket_floaty', + 'open' => 'ticket_open', + 'status' => 'ticket_status', + 'messagesNumber' => 'msgNumber', + 'lastUpdate' => 'last_update', + 'createDate' => 'create_date', + ], 'ObjectClass', [ + 'ticketId' => 'int', + 'title' => 'string', + 'floaty' => 'float', + 'open' => 'bool', + 'status' => 'int', + 'messagesNumber' => 'int', + 'lastUpdate' => 'int', + 'createDate' => 'int', + ], [ + 'ticketId' => false, + 'title' => false, + 'floaty' => false, + 'open' => false, + 'status' => false, + 'messagesNumber' => false, + 'lastUpdate' => false, + 'createDate' => false, + ]); + + $this->ticketRepository = new TestClasses\TicketRepositoryBuilderWriteable( + new RepositoryWriteable( + $this->db, + $this->ticketRepositoryConfig + ) + ); + + $this->ticketMessageRepositoryConfig = new RepositoryConfig('', 'tickets_messages', [ + 'msg_id' => 'messageId', + 'ticket_id' => 'ticketId', + 'email_id' => 'emailId', + 'sender_type' => 'senderType', + 'create_date' => 'createDate', + ], [ + 'messageId' => 'msg_id', + 'ticketId' => 'ticket_id', + 'emailId' => 'email_id', + 'senderType' => 'sender_type', + 'createDate' => 'create_date', + ], 'ObjectClass', [ + 'messageId' => 'int', + 'ticketId' => 'int', + 'emailId' => 'int', + 'senderType' => 'string', + 'createDate' => 'int', + ], [ + 'messageId' => false, + 'ticketId' => false, + 'emailId' => false, + 'senderType' => false, + 'createDate' => false, + ]); + + $this->ticketMessageRepository = new RepositoryWriteable($this->db, $this->ticketMessageRepositoryConfig); + + $this->emailRepository = new RepositoryWriteable($this->db, new RepositoryConfig( + '', + 'db74.emails', + [ + 'email_id' => 'emailId', + 'to_address' => 'to', + 'from_address' => 'from', + 'automatic' => 'automatic', + 'create_date' => 'createDate', + ], + [ + 'emailId' => 'email_id', + 'to' => 'to_address', + 'from' => 'from_address', + 'automatic' => 'automatic', + 'createDate' => 'create_date', + ], + 'ObjectClass', + [ + 'emailId' => 'int', + 'to' => 'string', + 'from' => 'string', + 'automatic' => 'bool', + 'createDate' => 'int', + ], + [ + 'emailId' => false, + 'to' => false, + 'from' => false, + 'automatic' => false, + 'createDate' => false, + ] + )); + + // Default query which is manipulated by all the tests + $this->query = [ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ], + 'tables' => [ + 'ticket', + ':message: LEFT JOIN :email: ON (:message.emailId: = :email.emailId: ' . + 'AND :email.automatic: = ?)' => true, + ], + 'changes' => [ + 'ticket.lastUpdate' => 5, + ':ticket.messagesNumber: = ?' => 13, + 'ticket.open' => true, + ':ticket.status: = 5', + ], + 'where' => [ + ':ticket.ticketId: = :message.ticketId:', + ':ticket.messagesNumber: > ?' => 13, + ':ticket.floaty: BETWEEN ? AND ?' => [5.5, 9.5], + 'message.senderType' => 'client', + ], + 'order' => [ + 'ticket.ticketId' => 'DESC', + 'updateMinusCreated', + '(:ticket.lastUpdate:-:ticket.createDate:)' => 'DESC', + ':ticket.lastUpdate:+:ticket.createDate:' => 'ASC', + ], + 'limit' => 30, + ]; + + // Default query which is manipulated by all the tests - freeform variant + $this->queryFreeform = [ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ], + 'query' => 'UPDATE :ticket:,:message: ' . + 'LEFT JOIN :email: ON (:message.emailId: = :email.emailId: AND :email.automatic: = ?) ' . + 'SET :ticket.lastUpdate:=?,:ticket.messagesNumber: = ? ' . + 'WHERE (:ticket.ticketId: = :message.ticketId:) ' . + 'ORDER BY ' . + ':ticket.ticketId: DESC,' . + 'updateMinusCreated ASC,' . + '(:ticket.lastUpdate:-:ticket.createDate:) DESC ' . + 'LIMIT 30', + 'parameters' => [ + true, + 5, + 13, + ], + ]; + } + + public function testMinimal() + { + // The query we want to receive + $expectedQuery = [ + 'changes' => [ + 'ticket.last_update' => 5, + 'ticket.ticket_open' => 1, + ], + 'tables' => [ + 'databasename.tickets ticket', + 'tickets_messages message', + ], + 'where' => [ + $this->db->quoteIdentifier('ticket.ticket_id') . + ' = ' . $this->db->quoteIdentifier('message.ticket_id'), + 'ticket.ticket_id' => [5, 13, 89], + 'ticket.ticket_open' => 0, + ], + ]; + + // Catch the call to the database + $this->db + ->shouldReceive('update') + ->once() + ->with($expectedQuery) + ->andReturn(13); + + // Attempt update + $results = $this->queryHandler->update([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + ], + 'changes' => [ + 'ticket.lastUpdate' => 5, + 'ticket.open' => true, + ], + 'where' => [ + ':ticket.ticketId: = :message.ticketId:', + 'ticket.ticketId' => [5, 13, 89], + 'ticket.open' => false, + ], + ]); + + // Make sure we received the correct sanitized results + $this->assertSame(13, $results); + } + + public function testComplicatedQuery() + { + // The query we want to receive + $expectedQuery = [ + 'changes' => [ + 'ticket.last_update' => 5, + $this->db->quoteIdentifier('ticket.msgNumber') . ' = ?' => 13, + 'ticket.ticket_open' => 1, + $this->db->quoteIdentifier('ticket.ticket_status') . ' = 5', + ], + 'tables' => [ + 'databasename.tickets ticket', + $this->db->quoteIdentifier('tickets_messages') . ' ' . $this->db->quoteIdentifier('message') . + ' LEFT JOIN ' . $this->db->quoteIdentifier('db74.emails') . ' ' . $this->db->quoteIdentifier('email') . + ' ON (' . $this->db->quoteIdentifier('message.email_id') . + ' = ' . $this->db->quoteIdentifier('email.email_id') . + ' AND ' . $this->db->quoteIdentifier('email.automatic') . ' = ?)' => true, + ], + 'where' => [ + $this->db->quoteIdentifier('ticket.ticket_id') . ' = ' . + $this->db->quoteIdentifier('message.ticket_id'), + $this->db->quoteIdentifier('ticket.msgNumber') . ' > ?' => 13, + $this->db->quoteIdentifier('ticket.ticket_floaty') . ' BETWEEN ? AND ?' => [5.5, 9.5], + 'message.sender_type' => 'client', + ], + 'order' => [ + 'ticket.ticket_id' => 'DESC', + 'updateMinusCreated', + '(:ticket.last_update:-:ticket.create_date:)' => 'DESC', + ':ticket.last_update:+:ticket.create_date:' => 'ASC', + ], + 'limit' => 30, + ]; + + // Catch the call to the database + $this->db + ->shouldReceive('update') + ->once() + ->with($expectedQuery) + ->andReturn(13); + + // Attempt update + $results = $this->queryHandler->update($this->query); + + // Make sure we received the correct sanitized results + $this->assertSame(13, $results); + } + + public function testFreeform() + { + // The values we want to receive + $expectedQuery = 'UPDATE ' . + $this->db->quoteIdentifier('databasename.tickets') . ' ' . $this->db->quoteIdentifier('ticket') . ',' . + $this->db->quoteIdentifier('tickets_messages') . ' ' . $this->db->quoteIdentifier('message') . + ' LEFT JOIN ' . $this->db->quoteIdentifier('db74.emails') . ' ' . $this->db->quoteIdentifier('email') . + ' ON (' . $this->db->quoteIdentifier('message.email_id') . ' = ' . + $this->db->quoteIdentifier('email.email_id') . ' AND ' . + $this->db->quoteIdentifier('email.automatic') . ' = ?) ' . + 'SET ' . $this->db->quoteIdentifier('ticket.last_update') . '=?,' . + $this->db->quoteIdentifier('ticket.msgNumber') . ' = ? ' . + 'WHERE (' . $this->db->quoteIdentifier('ticket.ticket_id') . ' = ' . + $this->db->quoteIdentifier('message.ticket_id') . ') ' . + 'ORDER BY ' . $this->db->quoteIdentifier('ticket.ticket_id') . ' DESC,' . + 'updateMinusCreated ASC,' . + '(' . $this->db->quoteIdentifier('ticket.last_update') . + '-' . $this->db->quoteIdentifier('ticket.create_date') . ') ' . + 'DESC LIMIT 30'; + $values = [1, 5, 13]; + + // Catch the call to the database + $this->db + ->shouldReceive('change') + ->once() + ->with(\Mockery::mustBe($expectedQuery), \Mockery::mustBe($values)) + ->andReturn(13); + + // Attempt update + $results = $this->queryHandler->update($this->queryFreeform); + + // Make sure we received the correct sanitized results + $this->assertSame(13, $results); + } + + public function testUnrecognizedOption() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid WHERE value + $this->query['unrecognized'] = [ + 'ticket.ticketId' => 5, + ]; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testInvalidWhere1Value() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid WHERE value + $this->query['where'] = [ + 'ticket.ticketIdUndefined' => 5, + ]; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testInvalidWhere2Value() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid WHERE value + $this->query['where'] = [ + ':ticket.ticketIdUndefined: = :message.ticketId:', + ]; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testInvalidWhere3Value() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid WHERE value + $this->query['where'] = [ + 1 => 5, + ]; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testInvalidWhere4Value() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid WHERE value + $this->query['where'] = []; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testInvalidChanges1Value() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SET value + $this->query['changes'] = []; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testInvalidChanges2Value() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SET value + $this->query['changes'] = [ + ':ticket.ticketIdInvalid: = 5', + ]; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testInvalidChanges3Value() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SET value + $this->query['changes'] = [ + 'ticket.ticketIdInvalid' => 5, + ]; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testInvalidChanges4Value() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SET value + $this->query['changes'] = [ + 'ticket.ticketIdInvalid' => new \stdClass(), + ]; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testInvalidChanges5Value() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SET value + $this->query['changes'] = [ + 0, + ]; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testInvalidChanges6Value() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid SET value + $this->query['changes'] = [ + 'ticket.ticketId', + ]; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testInvalidOrder1Value() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid ORDER value + $this->query['order'] = [ + ':ticket.ticketIdInvalid:', + ]; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testInvalidOrder2Value() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Try to test with some invalid ORDER value + $this->query['order'] = [ + new \stdClass(), + ]; + + // Attempt update + $this->queryHandler->update($this->query); + } + + public function testUnresolvedFreeform() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + $this->queryFreeform['query'] = $this->queryFreeform['query'] . ' :invalid:'; + + // Attempt update + $this->queryHandler->update($this->queryFreeform); + } + + public function testFreeformNoQuery() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + $this->queryFreeform['query'] = ''; + + // Attempt update + $this->queryHandler->update($this->queryFreeform); + } + + public function testFreeformNoTables() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + $this->queryFreeform['repositories'] = []; + + // Attempt update + $this->queryHandler->update($this->queryFreeform); + } + + public function testNoWriteRepository() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + $ticketRepository = new TicketRepositoryBuilderReadOnly( + new RepositoryReadOnly($this->db, $this->ticketRepositoryConfig) + ); + + $this->queryFreeform['repositories'] = [ + 'ticket' => $ticketRepository, + 'message' => $this->ticketMessageRepository, + 'email' => $this->emailRepository, + ]; + + // Attempt update + $this->queryHandler->update($this->queryFreeform); + } + + public function testNoWriteRepository2() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + $ticketMessageRepository = new RepositoryReadOnly($this->db, $this->ticketMessageRepositoryConfig); + + $this->queryFreeform['repositories'] = [ + 'ticket' => $this->ticketRepository, + 'message' => $ticketMessageRepository, + 'email' => $this->emailRepository, + ]; + + // Attempt update + $this->queryHandler->update($this->queryFreeform); + } + + public function testUpdateExceptionFromDbClass() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // The query we want to receive + $expectedQuery = [ + 'changes' => [ + 'ticket.last_update' => 5, + 'ticket.ticket_open' => 1, + ], + 'tables' => [ + 'databasename.tickets ticket', + 'tickets_messages message', + ], + 'where' => [ + $this->db->quoteIdentifier('ticket.ticket_id') . ' = ' . + $this->db->quoteIdentifier('message.ticket_id'), + 'ticket.ticket_id' => [5, 13, 89], + 'ticket.ticket_open' => 0, + ], + ]; + + // Catch the call to the database + $this->db + ->shouldReceive('update') + ->once() + ->with($expectedQuery) + ->andThrow(new DBInvalidOptionException('dada', 'file', 99, 'message')); + + // Attempt update + $this->queryHandler->update([ + 'repositories' => [ + 'ticket' => $this->ticketRepository, + 'message' => $this->ticketMessageRepository, + ], + 'changes' => [ + 'ticket.lastUpdate' => 5, + 'ticket.open' => true, + ], + 'where' => [ + ':ticket.ticketId: = :message.ticketId:', + 'ticket.ticketId' => [5, 13, 89], + 'ticket.open' => false, + ], + ]); + } + + public function testFreeformExceptionFromDbClass() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // The values we want to receive + $expectedQuery = 'UPDATE ' . + $this->db->quoteIdentifier('databasename.tickets') . ' ' . $this->db->quoteIdentifier('ticket') . ',' . + $this->db->quoteIdentifier('tickets_messages') . ' ' . $this->db->quoteIdentifier('message') . + ' LEFT JOIN ' . $this->db->quoteIdentifier('db74.emails') . ' ' . $this->db->quoteIdentifier('email') . + ' ON (' . $this->db->quoteIdentifier('message.email_id') . ' = ' . + $this->db->quoteIdentifier('email.email_id') . ' AND ' . + $this->db->quoteIdentifier('email.automatic') . ' = ?) ' . + 'SET ' . $this->db->quoteIdentifier('ticket.last_update') . '=?,' . + $this->db->quoteIdentifier('ticket.msgNumber') . ' = ? ' . + 'WHERE (' . + $this->db->quoteIdentifier('ticket.ticket_id') . ' = ' . + $this->db->quoteIdentifier('message.ticket_id') . ') ' . + 'ORDER BY ' . $this->db->quoteIdentifier('ticket.ticket_id') . ' DESC,' . + 'updateMinusCreated ASC,' . + '(' . $this->db->quoteIdentifier('ticket.last_update') . '-' . + $this->db->quoteIdentifier('ticket.create_date') . ') DESC ' . + 'LIMIT 30'; + $values = [1, 5, 13]; + + // Catch the call to the database + $this->db + ->shouldReceive('change') + ->once() + ->with(\Mockery::mustBe($expectedQuery), \Mockery::mustBe($values)) + ->andThrow(new DBInvalidOptionException('dada', 'file', 99, 'message')); + + // Attempt update + $this->queryHandler->update($this->queryFreeform); + } +} diff --git a/tests/RepositoryActions/CountEntriesTest.php b/tests/RepositoryActions/CountEntriesTest.php new file mode 100644 index 0000000..9d80834 --- /dev/null +++ b/tests/RepositoryActions/CountEntriesTest.php @@ -0,0 +1,62 @@ +repository = \Mockery::mock(RepositoryReadOnlyInterface::class); + } + + public function testNoDataGetEntries() + { + $selectBuilder = new CountEntries($this->repository); + + $this->repository + ->shouldReceive('count') + ->once() + ->with([ + 'where' => [], + 'lock' => false, + ]) + ->andReturn(5); + + $results = $selectBuilder->getNumber(); + + $this->assertEquals(5, $results); + } + + public function testGetEntries() + { + $selectBuilder = new CountEntries($this->repository); + + $selectBuilder + ->where([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->blocking(); + + $this->repository + ->shouldReceive('count') + ->once() + ->with([ + 'where' => [ + 'responseId' => 5, + 'otherField' => '333', + ], + 'lock' => true, + ]) + ->andReturn(55); + + $results = $selectBuilder->getNumber(); + + $this->assertEquals(55, $results); + } +} diff --git a/tests/RepositoryActions/DeleteEntriesTest.php b/tests/RepositoryActions/DeleteEntriesTest.php new file mode 100644 index 0000000..f7d3533 --- /dev/null +++ b/tests/RepositoryActions/DeleteEntriesTest.php @@ -0,0 +1,55 @@ +repository = \Mockery::mock(RepositoryWriteableInterface::class); + } + + public function testNoDataWrite() + { + $deleteBuilder = new DeleteEntries($this->repository); + + $this->repository + ->shouldReceive('delete') + ->once() + ->with([]) + ->andReturn(5); + + $deleteBuilder->write(); + + $this->assertTrue(true); + } + + public function testWrite() + { + $deleteBuilder = new DeleteEntries($this->repository); + + $deleteBuilder + ->where([ + 'responseId' => 5, + 'otherField' => '333', + ]); + + $this->repository + ->shouldReceive('delete') + ->once() + ->with([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->andReturn(55); + + $results = $deleteBuilder->writeAndReturnAffectedNumber(); + + $this->assertEquals(55, $results); + } +} diff --git a/tests/RepositoryActions/InsertEntryTest.php b/tests/RepositoryActions/InsertEntryTest.php new file mode 100644 index 0000000..14870d1 --- /dev/null +++ b/tests/RepositoryActions/InsertEntryTest.php @@ -0,0 +1,77 @@ +repository = \Mockery::mock(RepositoryWriteableInterface::class); + } + + public function testNoDataWrite() + { + $insertBuilder = new InsertEntry($this->repository); + + $this->repository + ->shouldReceive('insert') + ->once() + ->with([]); + + $insertBuilder->write(); + + $this->assertTrue(true); + } + + public function testWrite() + { + $insertBuilder = new InsertEntry($this->repository); + + $insertBuilder + ->set([ + 'responseId' => 5, + 'otherField' => '333', + ]); + + $this->repository + ->shouldReceive('insert') + ->once() + ->with([ + 'responseId' => 5, + 'otherField' => '333', + ]); + + $insertBuilder->write(); + + $this->assertTrue(true); + } + + public function testWriteWithNewId() + { + $insertBuilder = new InsertEntry($this->repository); + + $insertBuilder + ->set([ + 'responseId' => 5, + 'otherField' => '333', + ]); + + $this->repository + ->shouldReceive('insert') + ->once() + ->with([ + 'responseId' => 5, + 'otherField' => '333', + ], true) + ->andReturn(54); + + $insertId = $insertBuilder->writeAndReturnNewId(); + + $this->assertEquals(54, $insertId); + } +} diff --git a/tests/RepositoryActions/InsertOrUpdateEntryTest.php b/tests/RepositoryActions/InsertOrUpdateEntryTest.php new file mode 100644 index 0000000..646ee7c --- /dev/null +++ b/tests/RepositoryActions/InsertOrUpdateEntryTest.php @@ -0,0 +1,168 @@ +repository = \Mockery::mock(RepositoryWriteableInterface::class); + } + + public function testNoDataWrite() + { + $insertBuilder = new InsertOrUpdateEntry($this->repository); + + $this->repository + ->shouldReceive('insertOrUpdate') + ->once() + ->with([], [], []); + + $insertBuilder->write(); + + $this->assertTrue(true); + } + + public function testNoDataWriteAndReturnWhatHappened() + { + $insertBuilder = new InsertOrUpdateEntry($this->repository); + + $this->repository + ->shouldReceive('insertOrUpdate') + ->once() + ->with([], [], []) + ->andReturn('insert'); + + $whatHappened = $insertBuilder->writeAndReturnWhatHappened(); + + $this->assertEquals('insert', $whatHappened); + } + + public function testWrite() + { + $insertBuilder = new InsertOrUpdateEntry($this->repository); + + $insertBuilder + ->set([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->index([ + 'responseId', + ]); + + $this->repository + ->shouldReceive('insertOrUpdate') + ->once() + ->with([ + 'responseId' => 5, + 'otherField' => '333', + ], [ + 'responseId', + ], []) + ->andReturn('update'); + + $whatHappened = $insertBuilder->writeAndReturnWhatHappened(); + + $this->assertEquals('update', $whatHappened); + } + + public function testWriteIndexIsString() + { + $insertBuilder = new InsertOrUpdateEntry($this->repository); + + $insertBuilder + ->set([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->index('responseId'); + + $this->repository + ->shouldReceive('insertOrUpdate') + ->once() + ->with([ + 'responseId' => 5, + 'otherField' => '333', + ], [ + 'responseId', + ], []) + ->andReturn('update'); + + $insertBuilder->write(); + + $this->assertTrue(true); + } + + public function testWriteSetUpdates() + { + $insertBuilder = new InsertOrUpdateEntry($this->repository); + + $insertBuilder + ->set([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->index([ + 'responseId', + ]) + ->setOnUpdate([ + 'otherField' => '66', + 'ladida' => true, + ]); + + $this->repository + ->shouldReceive('insertOrUpdate') + ->once() + ->with([ + 'responseId' => 5, + 'otherField' => '333', + ], [ + 'responseId', + ], [ + 'otherField' => '66', + 'ladida' => true, + ]); + + $insertBuilder->write(); + + $this->assertTrue(true); + } + + public function testWriteSetUpdatesAsString() + { + $insertBuilder = new InsertOrUpdateEntry($this->repository); + + $insertBuilder + ->set([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->index([ + 'responseId', + ]) + ->setOnUpdate(':otherField: = :otherField: + 1'); + + $this->repository + ->shouldReceive('insertOrUpdate') + ->once() + ->with([ + 'responseId' => 5, + 'otherField' => '333', + ], [ + 'responseId', + ], [ + ':otherField: = :otherField: + 1', + ]) + ->andReturn(''); + + $whatHappened = $insertBuilder->writeAndReturnWhatHappened(); + + $this->assertEquals('', $whatHappened); + } +} diff --git a/tests/RepositoryActions/SelectEntriesTest.php b/tests/RepositoryActions/SelectEntriesTest.php new file mode 100644 index 0000000..794510e --- /dev/null +++ b/tests/RepositoryActions/SelectEntriesTest.php @@ -0,0 +1,257 @@ +repository = \Mockery::mock(RepositoryReadOnlyInterface::class); + } + + public function testNoDataGetEntries() + { + $selectBuilder = new SelectEntries($this->repository); + + $this->repository + ->shouldReceive('select') + ->once() + ->with([ + 'where' => [], + 'order' => [], + 'fields' => [], + 'limit' => 0, + 'offset' => 0, + 'lock' => false, + ]) + ->andReturn([]); + + $results = $selectBuilder->getEntries(); + + $this->assertEquals([], $results); + } + + public function testGetEntries() + { + $selectBuilder = new SelectEntries($this->repository); + + $selectBuilder + ->where([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->orderBy([ + 'responseId' => 'DESC', + ]) + ->startAt(13) + ->limitTo(45) + ->blocking() + ->fields([ + 'responseId', + 'otherField', + ]); + + $this->repository + ->shouldReceive('select') + ->once() + ->with([ + 'where' => [ + 'responseId' => 5, + 'otherField' => '333', + ], + 'order' => [ + 'responseId' => 'DESC', + ], + 'fields' => [ + 'responseId', + 'otherField', + ], + 'limit' => 45, + 'offset' => 13, + 'lock' => true, + ]) + ->andReturn([]); + + $results = $selectBuilder->getEntries(); + + $this->assertEquals([], $results); + } + + public function testGetEntriesFieldAndStringOrder() + { + $selectBuilder = new SelectEntries($this->repository); + + $selectBuilder + ->where([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->orderBy('responseId') + ->startAt(13) + ->limitTo(45) + ->blocking() + ->field('responseId'); + + $this->repository + ->shouldReceive('select') + ->once() + ->with([ + 'where' => [ + 'responseId' => 5, + 'otherField' => '333', + ], + 'order' => [ + 'responseId', + ], + 'fields' => [ + 'responseId', + ], + 'limit' => 45, + 'offset' => 13, + 'lock' => true, + ]) + ->andReturn([]); + + $results = $selectBuilder->getEntries(); + + $this->assertEquals([], $results); + } + + public function testNoDataGetOneEntry() + { + $selectBuilder = new SelectEntries($this->repository); + + $this->repository + ->shouldReceive('selectOne') + ->once() + ->with([ + 'where' => [], + 'order' => [], + 'fields' => [], + 'offset' => 0, + 'lock' => false, + ]) + ->andReturn([]); + + $results = $selectBuilder->getOneEntry(); + + $this->assertEquals([], $results); + } + + public function testGetOneEntry() + { + $selectBuilder = new SelectEntries($this->repository); + + $selectBuilder + ->where([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->orderBy([ + 'responseId' => 'DESC', + ]) + ->startAt(13) + ->blocking() + ->fields([ + 'responseId', + 'otherField', + ]); + + $this->repository + ->shouldReceive('selectOne') + ->once() + ->with([ + 'where' => [ + 'responseId' => 5, + 'otherField' => '333', + ], + 'order' => [ + 'responseId' => 'DESC', + ], + 'fields' => [ + 'responseId', + 'otherField', + ], + 'offset' => 13, + 'lock' => true, + ]) + ->andReturn([]); + + $results = $selectBuilder->getOneEntry(); + + $this->assertEquals([], $results); + } + + public function testNoDataGetFlattenedFields() + { + $selectBuilder = new SelectEntries($this->repository); + + $this->repository + ->shouldReceive('selectFlattenedFields') + ->once() + ->with([ + 'where' => [], + 'order' => [], + 'fields' => [], + 'limit' => 0, + 'offset' => 0, + 'lock' => false, + ]) + ->andReturn([]); + + $results = $selectBuilder->getFlattenedFields(); + + $this->assertEquals([], $results); + } + + public function testGetFlattenedFields() + { + $selectBuilder = new SelectEntries($this->repository); + + $selectBuilder + ->where([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->orderBy([ + 'responseId' => 'DESC', + ]) + ->startAt(13) + ->limitTo(45) + ->blocking() + ->fields([ + 'responseId', + 'otherField', + ]); + + $this->repository + ->shouldReceive('selectFlattenedFields') + ->once() + ->with([ + 'where' => [ + 'responseId' => 5, + 'otherField' => '333', + ], + 'order' => [ + 'responseId' => 'DESC', + ], + 'fields' => [ + 'responseId', + 'otherField', + ], + 'limit' => 45, + 'offset' => 13, + 'lock' => true, + ]) + ->andReturn([]); + + $results = $selectBuilder->getFlattenedFields(); + + $this->assertEquals([], $results); + } +} diff --git a/tests/RepositoryActions/UpdateEntriesTest.php b/tests/RepositoryActions/UpdateEntriesTest.php new file mode 100644 index 0000000..cbc0a69 --- /dev/null +++ b/tests/RepositoryActions/UpdateEntriesTest.php @@ -0,0 +1,181 @@ +repository = \Mockery::mock(RepositoryWriteableInterface::class); + } + + public function testNoDataWrite() + { + $updateBuilder = new UpdateEntries($this->repository); + + $this->repository + ->shouldReceive('update') + ->once() + ->with([ + 'changes' => [], + 'where' => [], + 'order' => [], + 'limit' => 0, + ]); + + $updateBuilder->write(); + + $this->assertTrue(true); + } + + public function testNoDataWriteAndReturnAffectedNumber() + { + $updateBuilder = new UpdateEntries($this->repository); + + $this->repository + ->shouldReceive('update') + ->once() + ->with([ + 'changes' => [], + 'where' => [], + 'order' => [], + 'limit' => 0, + ]) + ->andReturn(89); + + $result = $updateBuilder->writeAndReturnAffectedNumber(); + + $this->assertEquals(89, $result); + } + + public function testWrite() + { + $updateBuilder = new UpdateEntries($this->repository); + + $updateBuilder + ->set([ + 'dada' => 5, + 'fieldyField' => 'key', + ]) + ->where([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->orderBy([ + 'dada', + 'responseId', + ]) + ->limitTo(6); + + $this->repository + ->shouldReceive('update') + ->once() + ->with([ + 'changes' => [ + 'dada' => 5, + 'fieldyField' => 'key', + ], + 'where' => [ + 'responseId' => 5, + 'otherField' => '333', + ], + 'order' => [ + 'dada', + 'responseId', + ], + 'limit' => 6, + ]); + + $updateBuilder->write(); + + $this->assertTrue(true); + } + + public function testWriteAndReturnAffectedNumber() + { + $updateBuilder = new UpdateEntries($this->repository); + + $updateBuilder + ->set([ + 'dada' => 5, + 'fieldyField' => 'key', + ]) + ->where([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->orderBy([ + 'dada', + 'responseId', + ]) + ->limitTo(6); + + $this->repository + ->shouldReceive('update') + ->once() + ->with([ + 'changes' => [ + 'dada' => 5, + 'fieldyField' => 'key', + ], + 'where' => [ + 'responseId' => 5, + 'otherField' => '333', + ], + 'order' => [ + 'dada', + 'responseId', + ], + 'limit' => 6, + ]) + ->andReturn(75); + + $result = $updateBuilder->writeAndReturnAffectedNumber(); + + $this->assertEquals(75, $result); + } + + public function testWriteOrderByAsString() + { + $updateBuilder = new UpdateEntries($this->repository); + + $updateBuilder + ->set([ + 'dada' => 5, + 'fieldyField' => 'key', + ]) + ->where([ + 'responseId' => 5, + 'otherField' => '333', + ]) + ->orderBy('responseId') + ->limitTo(6); + + $this->repository + ->shouldReceive('update') + ->once() + ->with([ + 'changes' => [ + 'dada' => 5, + 'fieldyField' => 'key', + ], + 'where' => [ + 'responseId' => 5, + 'otherField' => '333', + ], + 'order' => [ + 'responseId', + ], + 'limit' => 6, + ]); + + $updateBuilder->write(); + + $this->assertTrue(true); + } +} diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php new file mode 100644 index 0000000..fa13e5f --- /dev/null +++ b/tests/RepositoryTest.php @@ -0,0 +1,1779 @@ +id = 5; + $obj1->firstName = 'Andreas'; + $obj1->lastName = 'Baumann'; + $obj1->street = 'Müllerstrasse'; + $obj1->number = 888; + $obj1->floatVal = 13.93; + $obj1->isGreat = true; + + $obj2 = new TestClasses\ObjData(); + $obj2->id = 13; + $obj2->firstName = 'Ben'; + $obj2->lastName = 'Baumann'; + $obj2->street = 'Mustermann'; + $obj2->number = 934; + $obj2->floatVal = 7.2; + $obj2->isGreat = false; + + $this->basicData = [ + 'dbResults1' => [ + [ + 'id' => 5, + 'first_name' => 'Andreas', + 'last_name' => 'Baumann', + 'street' => 'Müllerstrasse', + 'number' => '888', + 'float_val' => '13.93', + 'is_great_yay' => '1', + 'blabla' => '5', + ], + ], + 'dbResults2' => [ + [ + 'id' => 5, + 'first_name' => 'Andreas', + 'last_name' => 'Baumann', + 'street' => 'Müllerstrasse', + 'number' => '888', + 'float_val' => '13.93', + 'is_great_yay' => '1', + 'blabla' => '5', + ], + [ + 'id' => 13, + 'first_name' => 'Ben', + 'last_name' => 'Baumann', + 'street' => 'Mustermann', + 'number' => '934', + 'float_val' => '7.2', + 'is_great_yay' => '0', + 'blabla' => '77', + ], + ], + 'obj1' => $obj1, + 'obj2' => $obj2, + ]; + + // Initialize DB mock + $this->db = \Mockery::mock(DBInterfaceForTests::class)->makePartial(); + + // Repository configuration + $this->repositoryConfig = new RepositoryConfig( + 'defaultConnection', + 'example', + [ + 'id' => 'id', + 'first_name' => 'firstName', + 'last_name' => 'lastName', + 'street' => 'street', + 'number' => 'number', + 'float_val' => 'floatVal', + 'is_great_yay' => 'isGreat', + ], + [ + 'id' => 'id', + 'firstName' => 'first_name', + 'lastName' => 'last_name', + 'street' => 'street', + 'number' => 'number', + 'floatVal' => 'float_val', + 'isGreat' => 'is_great_yay', + ], + TestClasses\ObjData::class, + [ + 'id' => 'int', + 'firstName' => 'string', + 'lastName' => 'string', + 'street' => 'string', + 'number' => 'int', + 'floatVal' => 'float', + 'isGreat' => 'bool', + ], + [ + 'id' => false, + 'firstName' => false, + 'lastName' => false, + 'street' => true, + 'number' => false, + 'floatVal' => false, + 'isGreat' => false, + ] + ); + + // Initialize repository + $this->repository = new RepositoryWriteable($this->db, $this->repositoryConfig); + } + + public function testConnectionNameInConfig() + { + $this->assertSame('defaultConnection', $this->repositoryConfig->getConnectionName()); + } + + public function testSelect() + { + // What values we want to see and return in our DB class + $results = $this->basicData['dbResults2']; + + // Define the structured query we expect to generate + $query = [ + 'where' => [ + 'last_name' => 'Baumann', + ], + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->dbSetupSelect($query, $results); + + // Make call to repository + $results = $this->repository->select([ + 'where' => [ + 'lastName' => 'Baumann', + ], + ]); + + // Make sure the correct objects were returned + $this->assertEquals([$this->basicData['obj1'], $this->basicData['obj2']], $results); + } + + /** + * Set up mock DB for findBy tests + * + * @param array $query + * @param array $results + */ + protected function dbSetupSelect(array $query, array $results) + { + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($query) + ->andReturn($results); + } + + public function testSelectWithLock() + { + // What values we want to see and return in our DB class + $results = $this->basicData['dbResults2']; + + // Define the structured query we expect to generate + $query = [ + 'where' => [ + 'last_name' => 'Baumann', + $this->db->quoteIdentifier('is_great_yay') . ' = ?' => 1, + ], + 'lock' => true, + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->dbSetupSelect($query, $results); + + // Make call to repository + $results = $this->repository->select([ + 'where' => [ + 'lastName' => 'Baumann', + ':isGreat: = ?' => true, + ], + 'lock' => true, + ]); + + // Make sure the correct objects were returned + $this->assertEquals([$this->basicData['obj1'], $this->basicData['obj2']], $results); + } + + public function testSelectNoRestrictions() + { + // What values we want to see and return in our DB class + $results = $this->basicData['dbResults2']; + + // Define the structured query we expect to generate + $query = [ + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->dbSetupSelect($query, $results); + + // Make call to repository + $results = $this->repository->select([]); + + // Make sure the correct objects were returned + $this->assertEquals([$this->basicData['obj1'], $this->basicData['obj2']], $results); + } + + public function testSelectNoRestrictionsLimitOffset() + { + // What values we want to see and return in our DB class + $results = $this->basicData['dbResults2']; + + // Define the structured query we expect to generate + $query = [ + 'limit' => 2, + 'offset' => 5, + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->dbSetupSelect($query, $results); + + // Make call to repository + $results = $this->repository->select(['limit' => 2, 'offset' => 5]); + + // Make sure the correct objects were returned + $this->assertEquals([$this->basicData['obj1'], $this->basicData['obj2']], $results); + } + + public function testSelectLimitOffsetOrderByNameOnlySomeFields() + { + // What values we want to see and return in our DB class + $results = $this->basicData['dbResults2']; + + // Define the structured query we expect to generate + $query = [ + 'fields' => [ + 'first_name', + 'street', + ], + 'order' => [ + 'last_name' => 'DESC', + ], + 'limit' => 2, + 'offset' => 5, + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->dbSetupSelect($query, $results); + + // Make call to repository + $results = $this->repository->select([ + 'fields' => [ + 'firstName', + 'street', + ], + 'order' => [ + 'lastName' => 'DESC', + ], + 'limit' => 2, + 'offset' => 5, + ]); + + // Make sure the correct objects were returned + $this->assertEquals([$this->basicData['obj1'], $this->basicData['obj2']], $results); + } + + public function testSelectFlattenedField() + { + // What values we want to see and return in our DB class + $results = [['id' => '63'], ['id' => '87']]; + + // Define the structured query we expect to generate + $query = [ + 'where' => [ + 'last_name' => 'Baumann', + ], + 'fields' => [ + 'id', + ], + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->dbSetupSelect($query, $results); + + // Make call to repository + $results = $this->repository->selectFlattenedFields([ + 'where' => [ + 'lastName' => 'Baumann', + ], + 'fields' => [ + 'id', + ], + ]); + + // Make sure the correct objects were returned + $this->assertEquals([63, 87], $results); + + // Make call to repository + $results = $this->repository->selectFlattenedFields([ + 'where' => [ + 'lastName' => 'Baumann', + ], + 'field' => 'id', + ]); + + // Make sure the correct objects were returned + $this->assertEquals([63, 87], $results); + } + + public function testSelectFlattenedFields() + { + // What values we want to see and return in our DB class + $results = [['id' => '63', 'first_name' => 'ladida'], ['id' => '87', 'first_name' => 'nice']]; + + // Define the structured query we expect to generate + $query = [ + 'where' => [ + 'last_name' => 'Baumann', + ], + 'fields' => [ + 'id', + 'first_name', + ], + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->dbSetupSelect($query, $results); + + // Make call to repository + $results = $this->repository->selectFlattenedFields([ + 'where' => [ + 'lastName' => 'Baumann', + ], + 'fields' => [ + 'id', + 'firstName', + ], + ]); + + // Make sure the correct objects were returned + $this->assertEquals([63, 'ladida', 87, 'nice'], $results); + } + + public function testSelectWithNULL() + { + // What values we want to see and return in our DB class + $results = $this->basicData['dbResults2']; + $results[1]['street'] = null; + + $obj2 = clone $this->basicData['obj2']; + $obj2->street = null; + + // Define the structured query we expect to generate + $query = [ + 'where' => [ + 'street' => null, + ], + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->dbSetupSelect($query, $results); + + // Make call to repository + $results = $this->repository->select([ + 'where' => [ + 'street' => null, + ], + ]); + + // Make sure the correct objects were returned + $this->assertEquals([$this->basicData['obj1'], $obj2], $results); + } + + public function testSelectComplexWhereAndOrder() + { + // What values we want to see and return in our DB class + $results = $this->basicData['dbResults2']; + + // Define the structured query we expect to generate + $query = [ + 'where' => [ + 'last_name' => ['Baumann', 'Rotmann', 'Salamander'], + 'first_name' => ['Laumann'], + $this->db->quoteIdentifier('is_great_yay') . ' >= ? OR ' . + $this->db->quoteIdentifier('is_great_yay') . ' <= ?' => [ + 13, + 6, + ], + $this->db->quoteIdentifier('last_name') . ' != ' . $this->db->quoteIdentifier('first_name'), + ], + 'order' => [ + 'IF(' . $this->db->quoteIdentifier('is_great_yay') . ',0,1)' => 'ASC', + 'IF(' . $this->db->quoteIdentifier('is_great_yay') . ',0,1)', + 'last_name' => 'DESC', + 'first_name', + ], + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->dbSetupSelect($query, $results); + + // Make call to repository + $results = $this->repository->select([ + 'where' => [ + 'lastName' => ['Baumann', 'Rotmann', 'Salamander'], + 'firstName' => ['Laumann'], + ':isGreat: >= ? OR :isGreat: <= ?' => [13, 6], + ':lastName: != :firstName:', + ], + 'order' => [ + 'IF(:isGreat:,0,1)' => 'ASC', + 'IF(:isGreat:,0,1)', + 'lastName' => 'DESC', + 'firstName', + ], + ]); + + // Make sure the correct objects were returned + $this->assertEquals([$this->basicData['obj1'], $this->basicData['obj2']], $results); + } + + public function testSelectOne() + { + $repository = \Mockery::mock(RepositoryReadOnly::class)->makePartial(); + + // What values returned by the findBy method + $selectResults = [['id' => '63']]; + + // Define the where part of the query + $where = [ + 'lastName' => 'Baumann', + ]; + + // Options, second argument to findOneBy + $options = [ + 'where' => $where, + 'order' => [ + 'firstName', + ], + ]; + + // We expect this internal call + $repository + ->shouldReceive('select') + ->once() + ->with(\Mockery::mustBe($options + ['limit' => 1])) + ->andReturn($selectResults); + + // Make call to repository + $results = $repository->selectOne($options); + + // Make sure the correct results were returned + $this->assertEquals($selectResults[0], $results); + } + + public function testSelectOneValidLimit() + { + $repository = \Mockery::mock(RepositoryReadOnly::class)->makePartial(); + + // What values returned by the findBy method + $selectResults = [['id' => '63']]; + + // Define the where part of the query + $where = [ + 'lastName' => 'Baumann', + ]; + + // Options, second argument to findOneBy + $options = [ + 'where' => $where, + 'order' => [ + 'firstName', + ], + 'limit' => 1, + ]; + + // We expect this internal call + $repository + ->shouldReceive('select') + ->once() + ->with(\Mockery::mustBe($options)) + ->andReturn($selectResults); + + // Make call to repository + $results = $repository->selectOne($options); + + // Make sure the correct results were returned + $this->assertEquals($selectResults[0], $results); + } + + public function testCount() + { + // What values we want to see and return in our DB class + $results = ['num' => '13']; + + // Define the structured query we expect to generate + $query = [ + 'where' => [ + 'last_name' => ['Baumann', 'Rotmann', 'Salamander'], + 'first_name' => ['Laumann'], + $this->db->quoteIdentifier('is_great_yay') . ' >= ? OR ' . + $this->db->quoteIdentifier('is_great_yay') . ' <= ?' => [ + 13, + 6, + ], + $this->db->quoteIdentifier('last_name') . ' != ' . $this->db->quoteIdentifier('first_name'), + ], + 'fields' => [ + 'num' => 'COUNT(*)', + ], + 'lock' => false, + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // What we expect to get + $this->db + ->shouldReceive('fetchOne') + ->once() + ->with($query) + ->andReturn($results); + + // Make call to repository + $results = $this->repository->count([ + 'where' => [ + 'lastName' => ['Baumann', 'Rotmann', 'Salamander'], + 'firstName' => ['Laumann'], + ':isGreat: >= ? OR :isGreat: <= ?' => [13, 6], + ':lastName: != :firstName:', + ], + ]); + + // Make sure the correct objects were returned + $this->assertEquals(13, $results); + + // What values we want to see and return in our DB class + $results = ['num' => '13']; + + // Define the structured query we expect to generate + $query = [ + 'fields' => [ + 'num' => 'COUNT(*)', + ], + 'lock' => true, + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // What we expect to get + $this->db + ->shouldReceive('fetchOne') + ->once() + ->with($query) + ->andReturn($results); + + // Make call to repository + $results = $this->repository->count([ + 'lock' => true, + ]); + + // Make sure the correct objects were returned + $this->assertEquals(13, $results); + } + + public function testUpdate() + { + // What values we want to see and return in our DB class + $expectedResults = 17; + + // Define the structured query we expect to generate + $query = [ + 'changes' => [ + 'last_name' => 'Rotmann', + 'first_name' => 'Laumann', + $this->db->quoteIdentifier('street') . ' = CONCAT(?,?)' => ['First', 'Second'], + $this->db->quoteIdentifier('last_name') . ' = 13', + ], + 'where' => [ + 'last_name' => ['Baumann', 'Rotmann', 'Salamander'], + 'first_name' => ['Laumann'], + $this->db->quoteIdentifier('is_great_yay') . ' >= ? OR ' . + $this->db->quoteIdentifier('is_great_yay') . ' <= ?' => [ + 13, + 6, + ], + $this->db->quoteIdentifier('last_name') . ' != ' . $this->db->quoteIdentifier('first_name'), + ], + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // What we expect to get + $this->db + ->shouldReceive('update') + ->once() + ->with($query) + ->andReturn($expectedResults); + + // Make call to repository + $results = $this->repository->update([ + 'changes' => [ + 'lastName' => 'Rotmann', + 'firstName' => 'Laumann', + ':street: = CONCAT(?,?)' => ['First', 'Second'], + ':lastName: = 13', + ], + 'where' => [ + 'lastName' => ['Baumann', 'Rotmann', 'Salamander'], + 'firstName' => ['Laumann'], + ':isGreat: >= ? OR :isGreat: <= ?' => [13, 6], + ':lastName: != :firstName:', + ], + ]); + + // Make sure the correct objects were returned + $this->assertEquals($expectedResults, $results); + } + + public function testUpdateWithNULLAndOrderAndLimit() + { + // What values we want to see and return in our DB class + $expectedResults = 17; + + // Define the structured query we expect to generate + $query = [ + 'changes' => [ + 'last_name' => 'Rotmann', + 'street' => null, + ], + 'where' => [ + 'last_name' => ['Baumann', 'Rotmann', 'Salamander'], + 'first_name' => ['Laumann'], + ], + 'order' => [ + 'first_name', + ], + 'limit' => 3, + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // What we expect to get + $this->db + ->shouldReceive('update') + ->once() + ->with($query) + ->andReturn($expectedResults); + + // Make call to repository + $results = $this->repository->update([ + 'changes' => [ + 'lastName' => 'Rotmann', + 'street' => null, + ], + 'where' => [ + 'lastName' => ['Baumann', 'Rotmann', 'Salamander'], + 'firstName' => ['Laumann'], + ], + 'order' => [ + 'firstName', + ], + 'limit' => 3, + ]); + + // Make sure the correct objects were returned + $this->assertEquals($expectedResults, $results); + } + + public function testInsert() + { + // Convert object to array so we can use it as calling argument + $objectAsArray = (array)$this->basicData['obj1']; + unset($objectAsArray['unused']); + + // Convert types for exact matching + $processedAddArray = $this->basicData['dbResults1'][0]; + unset($processedAddArray['blabla']); + $processedAddArray['number'] = intval($processedAddArray['number']); + $processedAddArray['float_val'] = floatval($processedAddArray['float_val']); + $processedAddArray['is_great_yay'] = intval($processedAddArray['is_great_yay']); + + // Last insert ID + $lastInsertId = 77; + + // Set up DB class mock + $this->db + ->shouldReceive('insert') + ->once() + ->with(\Mockery::mustBe($this->repositoryConfig->getTableName()), \Mockery::mustBe($processedAddArray)); + + $this->db + ->shouldReceive('lastInsertId') + ->once() + ->andReturn($lastInsertId); + + // Make call to repository + $results = $this->repository->insert($objectAsArray, true); + + // Make sure the correct objects were returned + $this->assertEquals(77, $results); + } + + public function testInsertNULL() + { + // Convert object to array so we can use it as calling argument + $objectAsArray = (array)$this->basicData['obj1']; + unset($objectAsArray['unused']); + $objectAsArray['street'] = null; + + // Convert types for exact matching + $processedAddArray = $this->basicData['dbResults1'][0]; + unset($processedAddArray['blabla']); + $processedAddArray['number'] = intval($processedAddArray['number']); + $processedAddArray['float_val'] = floatval($processedAddArray['float_val']); + $processedAddArray['is_great_yay'] = intval($processedAddArray['is_great_yay']); + $processedAddArray['street'] = null; + + // Last insert ID + $lastInsertId = 77; + + // Set up DB class mock + $this->db + ->shouldReceive('insert') + ->once() + ->with(\Mockery::mustBe($this->repositoryConfig->getTableName()), \Mockery::mustBe($processedAddArray)); + + $this->db + ->shouldReceive('lastInsertId') + ->once() + ->andReturn($lastInsertId); + + // Make call to repository + $results = $this->repository->insert($objectAsArray, true); + + // Make sure the correct objects were returned + $this->assertEquals(77, $results); + } + + public function testInsertWithoutInsertId() + { + // Convert object to array so we can use it as calling argument + $objectAsArray = (array)$this->basicData['obj1']; + unset($objectAsArray['unused']); + + // Convert types for exact matching + $processedAddArray = $this->basicData['dbResults1'][0]; + unset($processedAddArray['blabla']); + $processedAddArray['number'] = intval($processedAddArray['number']); + $processedAddArray['float_val'] = floatval($processedAddArray['float_val']); + $processedAddArray['is_great_yay'] = intval($processedAddArray['is_great_yay']); + + // Set up DB class mock + $this->db + ->shouldReceive('insert') + ->once() + ->with(\Mockery::mustBe($this->repositoryConfig->getTableName()), \Mockery::mustBe($processedAddArray)); + + // Make call to repository + $results = $this->repository->insert($objectAsArray, false); + + // Make sure the correct objects were returned + $this->assertEquals(null, $results); + } + + public function testInsertOrUpdate() + { + // Convert object to array so we can use it as calling argument + $objectAsArray = [ + 'id' => 5, + 'firstName' => 'Andreas', + 'lastName' => 'Baumann', + 'street' => 'Müllerstrasse', + 'number' => '888', + 'floatVal' => '13.93', + 'isGreat' => '1', + ]; + + // Convert object to array so we can use it as calling argument + $insertAsArray = [ + 'id' => 5, + 'first_name' => 'Andreas', + 'last_name' => 'Baumann', + 'street' => 'Müllerstrasse', + 'number' => 888, + 'float_val' => 13.93, + 'is_great_yay' => 1, + ]; + + // Set up DB class mock + $this->dbSetupInsertOrUpdate('example', $insertAsArray, ['id'], []); + + // Make call to repository + $result = $this->repository->insertOrUpdate($objectAsArray, ['id']); + + // Make sure the correct objects were returned + $this->assertEquals('insert', $result); + } + + /** + * Set up mock DB for insert ON DUPLICATE KEY UPDATE tests + * + * @param string $tableName + * @param array $fields + * @param array $indexFields + * @param array $updateFields + * @param int $returnValue + */ + protected function dbSetupInsertOrUpdate( + string $tableName, + array $fields, + array $indexFields, + array $updateFields, + int $returnValue = 1 + ) { + // DB calls + $this->db + ->shouldReceive('upsert') + ->once() + ->with( + \Mockery::mustBe($tableName), + \Mockery::mustBe($fields), + \Mockery::mustBe($indexFields), + \Mockery::mustBe($updateFields) + ) + ->andReturn($returnValue); + } + + public function testInsertOrUpdateNoChange() + { + // Convert object to array so we can use it as calling argument + $objectAsArray = [ + 'id' => 5, + 'firstName' => 'Andreas', + 'lastName' => 'Baumann', + 'street' => 'Müllerstrasse', + 'number' => '888', + 'floatVal' => '13.93', + 'isGreat' => '1', + ]; + + // Convert object to array so we can use it as calling argument + $insertAsArray = [ + 'id' => 5, + 'first_name' => 'Andreas', + 'last_name' => 'Baumann', + 'street' => 'Müllerstrasse', + 'number' => 888, + 'float_val' => 13.93, + 'is_great_yay' => 1, + ]; + + // Set up DB class mock + $this->dbSetupInsertOrUpdate('example', $insertAsArray, ['id'], [], 0); + + // Make call to repository + $result = $this->repository->insertOrUpdate($objectAsArray, ['id']); + + // Make sure the correct objects were returned + $this->assertEquals('', $result); + } + + public function testInsertOrUpdateCustomUpdate() + { + // Convert object to array so we can use it as calling argument + $objectAsArray = [ + 'id' => 5, + 'firstName' => 'Andreas', + 'lastName' => 'Baumann', + 'street' => 'Müllerstrasse', + 'number' => '888', + 'floatVal' => '13.93', + 'isGreat' => '1', + ]; + + // Convert object to array so we can use it as calling argument + $insertAsArray = [ + 'id' => 5, + 'first_name' => 'Andreas', + 'last_name' => 'Baumann', + 'street' => 'Müllerstrasse', + 'number' => 888, + 'float_val' => 13.93, + 'is_great_yay' => 1, + ]; + + // Convert object to array so we can use it as calling argument + $updateAsArray = [ + 'number' => 888, + 'float_val' => 13.93, + 'is_great_yay' => 1, + ]; + + // Set up DB class mock + $this->dbSetupInsertOrUpdate('example', $insertAsArray, ['id'], $updateAsArray); + + // Make call to repository + $result = $this->repository->insertOrUpdate($objectAsArray, ['id'], [ + 'number' => '888', + 'floatVal' => '13.93', + 'isGreat' => '1', + ]); + + // Make sure the correct objects were returned + $this->assertEquals('insert', $result); + } + + public function testInsertOrUpdateCustomUpdate2() + { + // Convert object to array so we can use it as calling argument + $objectAsArray = [ + 'id' => 5, + 'firstName' => 'Andreas', + 'lastName' => 'Baumann', + 'street' => 'Müllerstrasse', + 'number' => '888', + 'floatVal' => '13.93', + 'isGreat' => '1', + ]; + + // Convert object to array so we can use it as calling argument + $insertAsArray = [ + 'id' => 5, + 'first_name' => 'Andreas', + 'last_name' => 'Baumann', + 'street' => 'Müllerstrasse', + 'number' => 888, + 'float_val' => 13.93, + 'is_great_yay' => 1, + ]; + + // Convert object to array so we can use it as calling argument + $updateAsArray = [ + $this->db->quoteIdentifier('number') . ' = ' . $this->db->quoteIdentifier('number') . ' + 1', + 'float_val' => 13.93, + 'is_great_yay' => 1, + ]; + + // Set up DB class mock + $this->dbSetupInsertOrUpdate('example', $insertAsArray, ['id'], $updateAsArray, 2); + + // Make call to repository + $result = $this->repository->insertOrUpdate($objectAsArray, ['id'], [ + ':number: = :number: + 1', + 'floatVal' => '13.93', + 'isGreat' => '1', + ]); + + // Make sure the correct objects were returned + $this->assertEquals('update', $result); + } + + public function testDelete() + { + // What values we want to see and return in our DB class + $expectedResults = 17; + + // Define the structured query we expect to generate + $query = [ + 'last_name' => ['Baumann', 'Rotmann', 'Salamander'], + 'first_name' => ['Laumann'], + $this->db->quoteIdentifier('is_great_yay') . ' >= ? OR ' . + $this->db->quoteIdentifier('is_great_yay') . ' <= ?' => [ + 13, + 6, + ], + $this->db->quoteIdentifier('last_name') . ' != ' . $this->db->quoteIdentifier('first_name'), + ]; + + // What we expect to get + $this->db + ->shouldReceive('delete') + ->once() + ->with($this->repositoryConfig->getTableName(), $query) + ->andReturn($expectedResults); + + // Make call to repository + $results = $this->repository->delete([ + 'lastName' => ['Baumann', 'Rotmann', 'Salamander'], + 'firstName' => ['Laumann'], + ':isGreat: >= ? OR :isGreat: <= ?' => [13, 6], + ':lastName: != :firstName:', + ]); + + // Make sure the correct objects were returned + $this->assertEquals($expectedResults, $results); + } + + public function testSelectUnknownOption() + { + // Expect an exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->select([ + 'order' => ['id' => 'DESC'], + 'limit' => 2, + 'offset' => 5, + 'bad' => 3, + ]); + } + + public function testSelectNotAnArrayOption() + { + // Expect an exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->select([ + 'order' => 'dada', + ]); + } + + public function testSelectUnknownWhereVariable() + { + // Expect an exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->select([ + 'where' => [ + 'unknown' => 'dada', + ], + ]); + } + + public function testSelectInvalidWhereExpression() + { + // Expect an exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->select([ + 'where' => [ + 0, + ], + ]); + } + + public function testSelectUnresolvedVariableInWhereExpression() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->select([ + 'where' => [ + ':unresolvedOption: = ?' => 'test', + ], + ]); + } + + public function testSelectUnknownFieldsName() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->select([ + 'where' => [ + 'lastName' => 'Baumann', + ], + 'fields' => [ + 'badFieldName', + ], + ]); + } + + public function testSelectInvalidOrderExpression() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->select([ + 'where' => [ + 'lastName' => 'Baumann', + ], + 'order' => [ + 0, + ], + ]); + } + + public function testSelectUnresolvedOrderExpression() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->select([ + 'where' => [ + 'lastName' => 'Baumann', + ], + 'order' => [ + 'IF(:unresolved:,0,1)' => 'ASC', + ], + ]); + } + + public function testSelectInvalidValueArrayInArray() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->select([ + 'where' => [ + 'firstName' => [['array']], + ], + ]); + } + + public function testSelectWithNULLNotNullable() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->select([ + 'where' => [ + 'firstName' => null, + ], + ]); + } + + public function testSelectNonBooleanFlattenFields() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->select([ + 'where' => [ + 'firstName' => 'dada', + ], + 'flattenFields' => 2, + ]); + } + + public function testSelectMissingObjectType() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // What values we want to see and return in our DB class + $results = $this->basicData['dbResults2']; + + // Define the structured query we expect to generate + $query = [ + 'where' => [ + 'last_name' => 'Baumann', + ], + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->dbSetupSelect($query, $results); + + // Repository configuration + $repositoryConfig = new RepositoryConfig( + '', + 'example', + [ + 'id' => 'id', + 'first_name' => 'firstName', + 'last_name' => 'lastName', + 'street' => 'street', + 'number' => 'number', + 'float_val' => 'floatVal', + 'is_great_yay' => 'isGreat', + ], + [ + 'id' => 'id', + 'firstName' => 'first_name', + 'lastName' => 'last_name', + 'street' => 'street', + 'number' => 'number', + 'floatVal' => 'float_val', + 'isGreat' => 'is_great_yay', + ], + TestClasses\ObjData::class, + [ + 'id' => 'int', + 'firstName' => 'string', + 'lastName' => 'string', + // 'street' value is missing here causing an exception + 'number' => 'int', + 'floatVal' => 'float', + 'isGreat' => 'bool', + ], + [ + 'id' => false, + 'firstName' => false, + 'lastName' => false, + 'street' => true, + 'number' => false, + 'floatVal' => false, + 'isGreat' => false, + ] + ); + + // Initialize repository + $repository = new RepositoryWriteable($this->db, $repositoryConfig); + + // Make call to repository + $repository->select([ + 'where' => [ + 'street' => 'Baumann', + ], + ]); + } + + public function testSelectInvalidObjectType() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // What values we want to see and return in our DB class + $results = $this->basicData['dbResults2']; + + // Define the structured query we expect to generate + $query = [ + 'where' => [ + 'last_name' => 'Baumann', + ], + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->dbSetupSelect($query, $results); + + // Repository configuration + $repositoryConfig = new RepositoryConfig( + '', + 'example', + [ + 'id' => 'id', + 'first_name' => 'firstName', + 'last_name' => 'lastName', + 'street' => 'street', + 'number' => 'number', + 'float_val' => 'floatVal', + 'is_great_yay' => 'isGreat', + ], + [ + 'id' => 'id', + 'firstName' => 'first_name', + 'lastName' => 'last_name', + 'street' => 'street', + 'number' => 'number', + 'floatVal' => 'float_val', + 'isGreat' => 'is_great_yay', + ], + TestClasses\ObjData::class, + [ + 'id' => 'int', + 'firstName' => 'string', + 'lastName' => 'string', + 'street' => 'fantasy', // invalid type + 'number' => 'int', + 'floatVal' => 'float', + 'isGreat' => 'bool', + ], + [ + 'id' => false, + 'firstName' => false, + 'lastName' => false, + 'street' => true, + 'number' => false, + 'floatVal' => false, + 'isGreat' => false, + ] + ); + + // Initialize repository + $repository = new RepositoryWriteable($this->db, $repositoryConfig); + + // Make call to repository + $repository->select([ + 'where' => [ + 'street' => 'Baumann', + ], + ]); + } + + public function testSelectOneWithInvalidLimit() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Options, second argument to findOneBy + $options = [ + 'where' => [ + 'lastName' => 'Baumann', + ], + 'order' => [ + 'firstName', + ], + 'limit' => 5, + ]; + + // Make call to repository + $this->repository->selectOne($options); + } + + public function testUpdateNoWhere() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->update([ + 'changes' => [ + 'firstName' => 'Sexyhexy', + ], + ]); + } + + public function testUpdateNoChanges() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->update([ + 'where' => [ + 'firstName' => 'Sexyhexy', + ], + ]); + } + + public function testUpdateNoChangeExpression() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->update([ + 'changes' => [ + 0, + ], + 'where' => [ + 'firstName' => 'Sexyhexy', + ], + ]); + } + + public function testUpdateInvalidChangeVariable() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->update([ + 'changes' => [ + 'firstNameInvalid' => 'Sexyhexy', + ], + 'where' => [ + 'lastName' => 'Baumann', + 'isGreat' => false, + ], + ]); + } + + public function testUpdateInvalidChangeExpression() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->update([ + 'changes' => [ + ':firstNameInvalid: = 5', + ], + 'where' => [ + 'lastName' => 'Baumann', + 'isGreat' => false, + ], + ]); + } + + public function testUpdateWithNULLNotNullable() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->update([ + 'changes' => [ + 'lastName' => null, + ], + 'where' => [ + 'lastName' => ['Baumann', 'Rotmann', 'Salamander'], + 'firstName' => ['Laumann'], + ], + ]); + } + + public function testInsertUnknownFieldName() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Insert values + $objectAsArray = [ + 'invalid' => 5, + ]; + + // Make call to repository + $this->repository->insert($objectAsArray, false); + } + + public function testInsertNullNotNullable() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Insert values + $objectAsArray = [ + 'lastName' => null, + ]; + + // Make call to repository + $this->repository->insert($objectAsArray, false); + } + + public function testInsertOrUpdateUnknownFieldName() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Insert values + $objectAsArray = [ + 'id' => 5, + 'invalid' => 5, + ]; + + // Make call to repository + $this->repository->insertOrUpdate($objectAsArray, ['id']); + } + + public function testInsertOrUpdateIndexNotOccuringInDataArray() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Insert values + $objectAsArray = [ + 'firstName' => 'dada', + ]; + + // Make call to repository + $this->repository->insertOrUpdate($objectAsArray, ['id']); + } + + public function testInsertOrUpdateNullForNotNullable() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Insert values + $objectAsArray = [ + 'id' => 5, + 'firstName' => null, + ]; + + // Make call to repository + $this->repository->insertOrUpdate($objectAsArray, ['id']); + } + + public function testRemoveNoWhere() + { + // Expect an InvalidArgument exception + $this->expectException(DBInvalidOptionException::class); + + // Make call to repository + $this->repository->delete([]); + } + + public function testBadObjValueCasting() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Bad repository config with isGreat set to unknown + $repositoryConfig = new RepositoryConfig( + '', + 'example', + [ + 'id' => 'id', + 'first_name' => 'firstName', + 'last_name' => 'lastName', + 'street' => 'street', + 'number' => 'number', + 'float_val' => 'floatVal', + 'is_great_yay' => 'isGreat', + ], + [ + 'id' => 'id', + 'firstName' => 'first_name', + 'lastName' => 'last_name', + 'street' => 'street', + 'number' => 'number', + 'floatVal' => 'float_val', + 'isGreat' => 'is_great_yay', + ], + TestClasses\ObjData::class, + [ + 'id' => 'int', + 'firstName' => 'string', + 'lastName' => 'string', + 'street' => 'string', + 'number' => 'int', + 'floatVal' => 'float', + 'isGreat' => 'unknown', + ], + [ + 'id' => false, + 'firstName' => false, + 'lastName' => false, + 'street' => false, + 'number' => false, + 'floatVal' => false, + 'isGreat' => false, + ] + ); + + // Initialize repository + $repository = new RepositoryReadOnly($this->db, $repositoryConfig); + + // What values we want to see and return in our DB class + $results = $this->basicData['dbResults2']; + + // Define the structured query we expect to generate + $query = [ + 'where' => [ + 'last_name' => 'Baumann', + ], + 'table' => $repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->dbSetupSelect($query, $results); + + // Make call to repository + $repository->select([ + 'where' => [ + 'lastName' => 'Baumann', + ], + ]); + } + + public function testSelectExceptionFromDbClass() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Define the structured query we expect to generate + $query = [ + 'where' => [ + 'last_name' => 'Baumann', + ], + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // Set up DB class mock + $this->db + ->shouldReceive('fetchAll') + ->once() + ->with($query) + ->andThrow(new DBInvalidOptionException('dada', 'file', 99, 'message')); + + // Make call to repository + $this->repository->select([ + 'where' => [ + 'lastName' => 'Baumann', + ], + ]); + } + + public function testCountExceptionFromDbClass() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Define the structured query we expect to generate + $query = [ + 'where' => [ + 'last_name' => ['Baumann', 'Rotmann', 'Salamander'], + ], + 'fields' => [ + 'num' => 'COUNT(*)', + ], + 'lock' => false, + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // What we expect to get + $this->db + ->shouldReceive('fetchOne') + ->once() + ->with($query) + ->andThrow(new DBInvalidOptionException('dada', 'file', 99, 'message')); + + // Make call to repository + $this->repository->count([ + 'where' => [ + 'lastName' => ['Baumann', 'Rotmann', 'Salamander'], + ], + ]); + } + + public function testUpdateExceptionFromDbClass() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Define the structured query we expect to generate + $query = [ + 'changes' => [ + 'last_name' => 'Rotmann', + ], + 'where' => [ + 'last_name' => ['Baumann', 'Rotmann', 'Salamander'], + ], + 'table' => $this->repositoryConfig->getTableName(), + ]; + + // What we expect to get + $this->db + ->shouldReceive('update') + ->once() + ->with($query) + ->andThrow(new DBInvalidOptionException('dada', 'file', 99, 'message')); + + // Make call to repository + $this->repository->update([ + 'changes' => [ + 'lastName' => 'Rotmann', + ], + 'where' => [ + 'lastName' => ['Baumann', 'Rotmann', 'Salamander'], + ], + ]); + } + + public function testInsertExceptionFromDbClass() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Convert object to array so we can use it as calling argument + $objectAsArray = (array)$this->basicData['obj1']; + unset($objectAsArray['unused']); + + // Convert types for exact matching + $processedAddArray = $this->basicData['dbResults1'][0]; + unset($processedAddArray['blabla']); + $processedAddArray['number'] = intval($processedAddArray['number']); + $processedAddArray['float_val'] = floatval($processedAddArray['float_val']); + $processedAddArray['is_great_yay'] = intval($processedAddArray['is_great_yay']); + + // Set up DB class mock + $this->db + ->shouldReceive('insert') + ->once() + ->with(\Mockery::mustBe($this->repositoryConfig->getTableName()), \Mockery::mustBe($processedAddArray)) + ->andThrow(new DBInvalidOptionException('dada', 'file', 99, 'message')); + + // Make call to repository + $this->repository->insert($objectAsArray, true); + } + + public function testInsertOrUpdateExceptionFromDbClass() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Convert object to array so we can use it as calling argument + $objectAsArray = [ + 'id' => 5, + 'firstName' => 'Andreas', + 'lastName' => 'Baumann', + 'street' => 'Müllerstrasse', + 'number' => '888', + 'floatVal' => '13.93', + 'isGreat' => '1', + ]; + + // Convert object to array so we can use it as calling argument + $insertAsArray = [ + 'id' => 5, + 'first_name' => 'Andreas', + 'last_name' => 'Baumann', + 'street' => 'Müllerstrasse', + 'number' => 888, + 'float_val' => 13.93, + 'is_great_yay' => 1, + ]; + + // Set up DB class mock + $this->db + ->shouldReceive('upsert') + ->once() + ->with( + \Mockery::mustBe('example'), + \Mockery::mustBe($insertAsArray), + \Mockery::mustBe(['id']), + \Mockery::mustBe([]) + ) + ->andThrow(new DBInvalidOptionException('dada', 'file', 99, 'message')); + + // Make call to repository + $this->repository->insertOrUpdate($objectAsArray, ['id']); + } + + public function testDeleteExceptionFromDbClass() + { + // Expect an invalid option exception + $this->expectException(DBInvalidOptionException::class); + + // Define the structured query we expect to generate + $query = [ + 'last_name' => ['Baumann', 'Rotmann', 'Salamander'], + ]; + + // What we expect to get + $this->db + ->shouldReceive('delete') + ->once() + ->with($this->repositoryConfig->getTableName(), $query) + ->andThrow(new DBInvalidOptionException('dada', 'file', 99, 'message')); + + // Make call to repository + $this->repository->delete([ + 'lastName' => ['Baumann', 'Rotmann', 'Salamander'], + ]); + } +} diff --git a/tests/TestClasses/ObjData.php b/tests/TestClasses/ObjData.php new file mode 100644 index 0000000..862cdbf --- /dev/null +++ b/tests/TestClasses/ObjData.php @@ -0,0 +1,49 @@ +repository = $repository; + } + + public function select(): SelectEntries + { + return new SelectEntries($this->repository); + } + + public function count(): CountEntries + { + return new CountEntries($this->repository); + } +} diff --git a/tests/TestClasses/TicketRepositoryBuilderWriteable.php b/tests/TestClasses/TicketRepositoryBuilderWriteable.php new file mode 100644 index 0000000..3218627 --- /dev/null +++ b/tests/TestClasses/TicketRepositoryBuilderWriteable.php @@ -0,0 +1,45 @@ +repository = $repository; + parent::__construct($repository); + } + + public function insert(): InsertEntry + { + return new InsertEntry($this->repository); + } + + public function insertOrUpdate(): InsertOrUpdateEntry + { + return new InsertOrUpdateEntry($this->repository); + } + + public function update(): UpdateEntries + { + return new UpdateEntries($this->repository); + } + + public function delete(): DeleteEntries + { + return new DeleteEntries($this->repository); + } +} diff --git a/tests/TestClasses/TicketRepositoryReadOnlyCorrectNameButInvalidDatabaseConnection.php b/tests/TestClasses/TicketRepositoryReadOnlyCorrectNameButInvalidDatabaseConnection.php new file mode 100644 index 0000000..6a8a047 --- /dev/null +++ b/tests/TestClasses/TicketRepositoryReadOnlyCorrectNameButInvalidDatabaseConnection.php @@ -0,0 +1,61 @@ +db = new \stdClass(); + $this->config = $config; + } + + /** + * @inheritDoc + */ + public function select(array $query): array + { + return []; + } + + /** + * @inheritDoc + */ + public function selectOne(array $query) + { + return []; + } + + /** + * @inheritDoc + */ + public function selectFlattenedFields(array $query): array + { + return []; + } + + /** + * @inheritDoc + */ + public function count(array $query): int + { + return 6; + } +} diff --git a/tests/TestClasses/TicketRepositoryReadOnlyDifferentRepositoryBuilderVariableWithin.php b/tests/TestClasses/TicketRepositoryReadOnlyDifferentRepositoryBuilderVariableWithin.php new file mode 100644 index 0000000..e0e522e --- /dev/null +++ b/tests/TestClasses/TicketRepositoryReadOnlyDifferentRepositoryBuilderVariableWithin.php @@ -0,0 +1,31 @@ +repositoryDifferentName = $repository; + } + + public function select(): SelectEntries + { + return new SelectEntries($this->repositoryDifferentName); + } + + public function count(): CountEntries + { + return new CountEntries($this->repositoryDifferentName); + } +} diff --git a/tests/TransactionTest.php b/tests/TransactionTest.php new file mode 100644 index 0000000..ef5047c --- /dev/null +++ b/tests/TransactionTest.php @@ -0,0 +1,255 @@ +repositoryConfig = new RepositoryConfig( + '', + 'example', + [ + 'id' => 'id', + 'first_name' => 'firstName', + 'last_name' => 'lastName', + 'street' => 'street', + 'number' => 'number', + 'float_val' => 'floatVal', + 'is_great_yay' => 'isGreat', + ], + [ + 'id' => 'id', + 'firstName' => 'first_name', + 'lastName' => 'last_name', + 'street' => 'street', + 'number' => 'number', + 'floatVal' => 'float_val', + 'isGreat' => 'is_great_yay', + ], + TestClasses\ObjData::class, + [ + 'id' => 'int', + 'firstName' => 'string', + 'lastName' => 'string', + 'street' => 'string', + 'number' => 'int', + 'floatVal' => 'float', + 'isGreat' => 'unknown', + ], + [ + 'id' => false, + 'firstName' => false, + 'lastName' => false, + 'street' => false, + 'number' => false, + 'floatVal' => false, + 'isGreat' => false, + ] + ); + } + + public function testRun() + { + // Transaction function to execute + $function = function () { + return 5; + }; + + /** + * Initialize DB mock + * + * @var DBInterfaceForTests|\Mockery\MockInterface $db + */ + $db = \Mockery::mock(DBInterfaceForTests::class)->makePartial(); + + // DB call - basically just passing through the call to DBInterface::transaction + $db + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($function)) + ->andReturnUsing($function); + + // Transaction handler instance + $transactionHandler = new Transaction($db); + + // Give the transaction handler the callback (no arguments) + $result = $transactionHandler->run($function); + + // Check that the function returned the correct result + $this->assertEquals(5, $result); + } + + public function testRunWithArguments() + { + // The three arguments used + $a = 2; + $b = 3; + $c = 37; + + // Transaction function to execute + $function = function ($a, $b, $c) { + return $a + $b + $c; + }; + + /** + * Initialize DB mock + * + * @var DBInterfaceForTests|\Mockery\MockInterface $db + */ + $db = \Mockery::mock(DBInterfaceForTests::class)->makePartial(); + + // DB call - basically just passing through the call to DBInterface::transaction + $db + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($function), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andReturnUsing(function ($function, $a, $b, $c) { + return $function($a, $b, $c); + }); + + // Transaction handler instance + $transactionHandler = new Transaction($db); + + // Give the transaction handler the callback plus arguments + $result = $transactionHandler->run($function, $a, $b, $c); + + // Check that the function calculated the correct result + $this->assertEquals(42, $result); + } + + public function testRunFromRepositoriesWithArguments() + { + // The three arguments used + $a = 2; + $b = 3; + $c = 37; + + // Transaction function to execute + $function = function ($a, $b, $c) { + return $a + $b + $c; + }; + + /** + * Initialize DB mock + * + * @var DBInterfaceForTests|\Mockery\MockInterface $db + */ + $db = \Mockery::mock(DBInterfaceForTests::class)->makePartial(); + + // DB call - basically just passing through the call to DBInterface::transaction + $db + ->shouldReceive('transaction') + ->once() + ->with(\Mockery::mustBe($function), \Mockery::mustBe($a), \Mockery::mustBe($b), \Mockery::mustBe($c)) + ->andReturnUsing(function ($function, $a, $b, $c) { + return $function($a, $b, $c); + }); + + $repositories = [ + new RepositoryReadOnly($db, $this->repositoryConfig), + new TicketRepositoryBuilderReadOnly(new RepositoryReadOnly($db, $this->repositoryConfig)), + new TicketRepositoryBuilderWriteable(new RepositoryWriteable($db, $this->repositoryConfig)), + ]; + + // Transaction handler instance + $transactionHandler = Transaction::withRepositories($repositories); + + // Give the transaction handler the callback plus arguments + $result = $transactionHandler->run($function, $a, $b, $c); + + // Check that the function calculated the correct result + $this->assertEquals(42, $result); + } + + public function testFromRepositoriesNoClasses() + { + $this->expectException(DBInvalidOptionException::class); + + $repositories = []; + + Transaction::withRepositories($repositories); + } + + public function testFromRepositoriesNoRepository() + { + $this->expectException(DBInvalidOptionException::class); + + $repositories = [ + new \stdClass() + ]; + + Transaction::withRepositories($repositories); + } + + public function testFromRepositoriesBuilderRepositoryWithDifferentReflection() + { + $this->expectException(DBInvalidOptionException::class); + + /** + * Initialize DB mock + * + * @var DBInterfaceForTests|\Mockery\MockInterface $db + */ + $db = \Mockery::mock(DBInterfaceForTests::class)->makePartial(); + + $repositories = [ + new TicketRepositoryReadOnlyDifferentRepositoryBuilderVariableWithin( + new RepositoryReadOnly($db, $this->repositoryConfig) + ) + ]; + + Transaction::withRepositories($repositories); + } + + public function testFromRepositoriesBaseRepositoryWithDifferentReflection() + { + $this->expectException(DBInvalidOptionException::class); + + $repositories = [ + new TicketMessageRepositoryReadOnlyDifferentRepositoryVariableWithin() + ]; + + Transaction::withRepositories($repositories); + } + + public function testFromRepositoriesDifferentConnections() + { + $this->expectException(DBInvalidOptionException::class); + + /** + * Initialize DB mock + * + * @var DBInterfaceForTests|\Mockery\MockInterface $db + */ + $db = \Mockery::mock(DBInterfaceForTests::class)->makePartial(); + + /** + * Initialize second DB mock + * + * @var DBInterfaceForTests|\Mockery\MockInterface $db + */ + $db2 = \Mockery::mock(DBInterfaceForTests::class)->makePartial(); + + $repositories = [ + new RepositoryReadOnly($db, $this->repositoryConfig), + new TicketRepositoryBuilderReadOnly(new RepositoryReadOnly($db2, $this->repositoryConfig)), + ]; + + // Transaction handler instance + Transaction::withRepositories($repositories); + } +}