diff --git a/.circleci/config.yml b/.circleci/config.yml index 84ff0344..ff7e4471 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -26,7 +26,7 @@ jobs: if [[ ! -f vendor/autoload.php ]]; then curl https://getcomposer.org/composer-stable.phar --location --silent --output /usr/bin/composer; \ chmod +x /usr/bin/composer; \ - composer install --no-progress --no-interaction; \ + composer install --no-progress --no-interaction --ignore-platform-req=ext-xdebug; \ fi - save_cache: key: composer-{{ checksum "composer.json" }}-{{ checksum "composer.lock" }} @@ -93,7 +93,7 @@ jobs: if [[ ! -f vendor/autoload.php ]]; then curl https://getcomposer.org/composer-stable.phar --location --silent --output /usr/bin/composer; \ chmod +x /usr/bin/composer; \ - composer install --no-progress --no-interaction; \ + composer install --no-progress --no-interaction --ignore-platform-req=ext-xdebug; \ fi - save_cache: key: composer-{{ checksum "composer.json" }}-{{ checksum "composer.lock" }} diff --git a/.gitignore b/.gitignore index e6d98de3..de392a34 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ composer.lock .phpunit.cache .idea var/ +cov.xml # For these quality tools, the *.dist form should be in VCS and the forms # below should be reserved for local customizations. diff --git a/Makefile b/Makefile index f5ac45f5..76ca9a4a 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,15 @@ +dev: + nix shell github:loophp/nix-shell --impure + phpcs: - vendor/bin/phpcs -n + vendor/bin/phpcs -n --parallel=8 phpcbf: vendor/bin/phpcbf phpstan: vendor/bin/phpstan clear-result-cache - vendor/bin/phpstan analyse + php -d memory_limit=2G vendor/bin/phpstan analyse phpcsfixer: vendor/bin/php-cs-fixer fix --dry-run --allow-risky=yes --diff @@ -15,4 +18,12 @@ test: vendor/bin/phpunit --testdox infection: - vendor/bin/infection + XDEBUG_MODE=coverage vendor/bin/infection -j8 + +PHPUNIT_REPORT_PATH = /tmp/phpunit_coverage_report +coverage: + XDEBUG_MODE=coverage vendor/bin/phpunit \ + --coverage-clover cov.xml \ + --coverage-filter src \ + --coverage-html $(PHPUNIT_REPORT_PATH) + xdg-open $(PHPUNIT_REPORT_PATH)/index.html diff --git a/changelog.MD b/changelog.MD index c4a0083f..01a66d48 100644 --- a/changelog.MD +++ b/changelog.MD @@ -1,5 +1,14 @@ # Changelog +## Next version + +**Bugfixes** +* Use backticks `` ` `` for table and column names in `Wizaplace\Etl\Database\*` classes (see https://github.com/wizacode/php-etl/issues/117) + +**Miscellaneous** +* Nix shell dev environment `Makefile` command +* Code coverage `Makefile` command + ## 2.3 * Add the PHPDoc _@mixin Pipeline_ to the Etl class. * Add _mixed_ return type to _Wizaplace\Etl\Row\offsetGet_. diff --git a/composer.json b/composer.json index 52cc4a91..e2e595c5 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,16 @@ { "name": "wizaplace/php-etl", "description": "Extract, Transform and Load data using this PHP written migration library.", - "keywords": ["etl", "extract", "transform", "load", "extraction", "transformation", "data", "symfony"], + "keywords": [ + "etl", + "extract", + "transform", + "load", + "extraction", + "transformation", + "data", + "symfony" + ], "license": "MIT", "authors": [ { @@ -22,7 +31,7 @@ }, "require": { "php": "~8.1", - "softcreatr/jsonpath": "^0.7.2" + "softcreatr/jsonpath": "^0.8.3" }, "autoload": { "psr-4": { @@ -34,12 +43,13 @@ "ext-json": "*", "ext-pdo_sqlite": "*", "ext-xmlreader": "*", + "ext-xdebug": "*", "friendsofphp/php-cs-fixer": "^3.4", "infection/infection": ">=0.15", "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.100", - "phpstan/phpstan-deprecation-rules": "^0.12.6", - "phpstan/phpstan-strict-rules": "^0.12.11", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-deprecation-rules": "^1.1.4", + "phpstan/phpstan-strict-rules": "^1.5.1", "phpunit/phpunit": ">=8", "squizlabs/php_codesniffer": "^3.5" }, @@ -64,7 +74,10 @@ "phpstan --memory-limit=256M analyze" ], "test": "phpunit", - "check": ["@scan", "@test"] + "check": [ + "@scan", + "@test" + ] }, "minimum-stability": "dev", "prefer-stable": true diff --git a/docs/Extractors/Table.md b/docs/Extractors/Table.md index 29130feb..debbcdc8 100644 --- a/docs/Extractors/Table.md +++ b/docs/Extractors/Table.md @@ -40,7 +40,7 @@ $options = [Table::CONNECTION => 'app']; Array of conditions, each condition is either: - `key` equals `value` , or -- `key` _comparesTo_ `value` (comparesTo can be: =, <, <=, =>, >, or <>). +- `key` _comparesTo_ `value` (comparesTo can be: =, <, <=, >=, >, or <>). If you need more flexibility in the query creation, you may use the [Query extractor](Query.md). diff --git a/docs/img/etl.svg b/docs/img/etl.svg index 53524f1e..fdb70833 100644 --- a/docs/img/etl.svg +++ b/docs/img/etl.svg @@ -321,9 +321,9 @@ id="text896-3" y="-90.151337" x="35.713844" - style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;line-height:1.25;font-family:'Roboto Slab';-inkscape-font-specification:'Roboto Slab';text-align:justify;letter-spacing:0px;word-spacing:0px;text-anchor:start;fill:url(#fond);fill-opacity:1;stroke:none;stroke-width:0.52916664" + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;line-height:1.25;font-family:'sans-serif';-inkscape-font-specification:'sans-serif';text-align:justify;letter-spacing:0px;word-spacing:0px;text-anchor:start;fill:url(#fond);fill-opacity:1;stroke:none;stroke-width:0.52916664" xml:space="preserve"> + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Monospace';-inkscape-font-specification:'Monospace';stroke-width:0.26458332" /> @@ -398,7 +398,7 @@ style="opacity:1;fill:url(#extractor);fill-opacity:1;stroke:none;stroke-width:0.26458332;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal" /> Extractor + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:4px;font-family:'Monospace';-inkscape-font-specification:'Monospace';fill:url(#fond);stroke-width:0.26458332">Extractor Transformer + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:4px;font-family:'Monospace';-inkscape-font-specification:'Monospace';text-align:end;text-anchor:end;fill:url(#fond);stroke-width:0.26458332">Transformer ASSETS COLORS + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-family:'Monospace';-inkscape-font-specification:'Monospace';fill:url(#bordure);stroke-width:1.19711387">COLORS Json + style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:11.86666679px;font-family:'Monospace';-inkscape-font-specification:'Monospace';fill:url(#fond);stroke-width:0.52916664">Json - - - src - - + ./tests/Unit + + + src + + diff --git a/src/Database/Helpers.php b/src/Database/Helpers.php new file mode 100644 index 00000000..47951d61 --- /dev/null +++ b/src/Database/Helpers.php @@ -0,0 +1,38 @@ + + * @copyright Copyright (c) Wizacha + * @copyright Copyright (c) Leonardo Marquine + * @license MIT + */ + +declare(strict_types=1); + +namespace Wizaplace\Etl\Database; + +class Helpers +{ + public const DEFAULT_MASK = '{column}'; + public const BACKTICKED_MASK = '`{column}`'; + + /** + * Join array elements using a string mask. + */ + public static function implode( + array $columns, + string $mask = self::DEFAULT_MASK, + array $ignoreMask = ['*'] // No backticks for * + ): string { + $columns = array_map( + function ($column) use ($mask, $ignoreMask): string { + return \in_array($column, $ignoreMask, true) + ? $column + : str_replace(self::DEFAULT_MASK, $column, $mask); + }, + $columns + ); + + return implode(', ', $columns); + } +} diff --git a/src/Database/Query.php b/src/Database/Query.php index 65f56b8e..2a892d7a 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -30,8 +30,10 @@ class Query /** * The where constraints for the query. + * + * @var WhereInterface[] */ - protected array $wheres = []; + protected array $whereQueries = []; /** * Create a new Query instance. @@ -76,11 +78,13 @@ public function getBindings(): array * * @return $this */ - public function select(string $table, array $columns = ['*']): Query + public function select(string $table, array $columns = null): Query { - $columns = $this->implode($columns); + $columns = \is_null($columns) + ? '*' + : Helpers::implode($columns, Helpers::BACKTICKED_MASK); - $this->query[] = "select $columns from $table"; + $this->query[] = "SELECT $columns FROM `$table`"; return $this; } @@ -94,11 +98,11 @@ public function insert(string $table, array $columns): Query { $this->bindings = array_merge($this->bindings, array_values($columns)); - $values = $this->implode($columns, '?'); + $values = Helpers::implode($columns, '?'); - $columns = $this->implode(array_keys($columns)); + $columns = Helpers::implode(array_keys($columns), Helpers::BACKTICKED_MASK); - $this->query[] = "insert into $table ($columns) values ($values)"; + $this->query[] = "INSERT INTO `$table` ($columns) VALUES ($values)"; return $this; } @@ -112,9 +116,12 @@ public function update(string $table, array $columns): Query { $this->bindings = array_merge($this->bindings, array_values($columns)); - $columns = $this->implode(array_keys($columns), '{column} = ?'); + $columns = Helpers::implode( + array_keys($columns), + sprintf('%s = ?', Helpers::BACKTICKED_MASK), + ); - $this->query[] = "update $table set $columns"; + $this->query[] = "UPDATE `$table` SET $columns"; return $this; } @@ -126,7 +133,7 @@ public function update(string $table, array $columns): Query */ public function delete(string $table): Query { - $this->query[] = "delete from {$table}"; + $this->query[] = "DELETE FROM `$table`"; return $this; } @@ -139,15 +146,19 @@ public function delete(string $table): Query public function where(array $columns): Query { foreach ($columns as $column => $value) { - $condition = ['type' => 'Where', 'column' => $column, 'boolean' => 'and']; if (is_scalar($value)) { - $condition['operator'] = '='; - $condition['value'] = $value; + $operator = WhereOperator::Equal; } else { - $condition['operator'] = $value[0]; - $condition['value'] = $value[1]; + $operator = WhereOperator::from($value[0]); + $value = $value[1]; } - $this->wheres[] = $condition; + + $this->whereQueries[] = new WhereQuery( + boolean: WhereBoolean::And, + operator: $operator, + column: $column, + value: $value, + ); } return $this; @@ -160,24 +171,25 @@ public function where(array $columns): Query * * @return $this */ - public function whereIn($column, array $values, string $operator = 'in'): Query - { + public function whereIn( + $column, + array $values, + WhereOperator $operator = WhereOperator::In + ): Query { if (is_string($column)) { - $this->wheres[] = [ - 'type' => 'WhereIn', - 'column' => $column, - 'values' => $values, - 'operator' => $operator, - 'boolean' => 'and', - ]; + $this->whereQueries[] = new WhereInQuery( + boolean: WhereBoolean::And, + operator: $operator, + column: $column, + multipleValues: $values, + ); } else { - $this->wheres[] = [ - 'type' => 'CompositeWhereIn', - 'columns' => $column, - 'values' => $values, - 'operator' => $operator, - 'boolean' => 'and', - ]; + $this->whereQueries[] = new WhereInCompositeQuery( + boolean: WhereBoolean::And, + operator: $operator, + multipleColumns: $column, // :| + multipleValues: $values, + ); } return $this; @@ -192,7 +204,7 @@ public function whereIn($column, array $values, string $operator = 'in'): Query */ public function whereNotIn($column, array $values): Query { - return $this->whereIn($column, $values, 'not in'); + return $this->whereIn($column, $values, WhereOperator::NotIn); } /** @@ -200,96 +212,20 @@ public function whereNotIn($column, array $values): Query */ protected function compileWheres(): void { - if ([] === $this->wheres) { + if ([] === $this->whereQueries) { return; } - $this->query[] = 'where'; - - foreach ($this->wheres as $index => $condition) { - $method = 'compile' . $condition['type']; - - if (0 == $index) { - $condition['boolean'] = ''; - } - - $this->query[] = trim($this->{$method}($condition)); - } - } - - /** - * Compile the basic where statement. - */ - protected function compileWhere(array $where): string - { - // All these if, empty, are here to clean the legacy code before the fork. See the git history. - $boolean = array_key_exists('boolean', $where) ? $where['boolean'] : null; - $column = array_key_exists('column', $where) ? $where['column'] : null; - $operator = array_key_exists('operator', $where) ? $where['operator'] : null; - $value = array_key_exists('value', $where) ? $where['value'] : null; - - $this->bindings[] = $value; - - return "$boolean $column $operator ?"; - } - - /** - * Compile the where in statement. - */ - protected function compileWhereIn(array $where): string - { - // All these if, empty, are here to clean the legacy code before the fork. See the git history. - $boolean = array_key_exists('boolean', $where) ? $where['boolean'] : null; - $column = array_key_exists('column', $where) ? $where['column'] : null; - $operator = array_key_exists('operator', $where) ? $where['operator'] : null; - $values = array_key_exists('values', $where) ? $where['values'] : null; - - $this->bindings = array_merge($this->bindings, $values); - - $parameters = $this->implode($values, '?'); - - return "$boolean $column $operator ($parameters)"; - } - - /** - * Compile the composite where in statement. - */ - protected function compileCompositeWhereIn(array $where): string - { - // All these if, empty, are here to clean the legacy code before the fork. See the git history. - $boolean = array_key_exists('boolean', $where) ? $where['boolean'] : null; - $columns = array_key_exists('columns', $where) ? $where['columns'] : null; - $operator = array_key_exists('operator', $where) ? $where['operator'] : null; - $values = array_key_exists('values', $where) ? $where['values'] : null; - - sort($columns); - - $parameters = []; + $this->query[] = 'WHERE'; - foreach ($values as $value) { - ksort($value); + foreach ($this->whereQueries as $index => $whereQuery) { + $result = $whereQuery->compile($index); - $this->bindings = array_merge($this->bindings, array_values($value)); - - $parameters[] = "({$this->implode($value, '?')})"; + $this->query[] = $result->output; + $this->bindings = \array_merge( + $this->bindings, + $result->bindings, + ); } - - $parameters = $this->implode($parameters); - - $columns = $this->implode($columns); - - return "$boolean ($columns) $operator ($parameters)"; - } - - /** - * Join array elements using a string mask. - */ - protected function implode(array $columns, string $mask = '{column}'): string - { - $columns = array_map(function ($column) use ($mask): string { - return str_replace('{column}', $column, $mask); - }, $columns); - - return implode(', ', $columns); } } diff --git a/src/Database/Statement.php b/src/Database/Statement.php index ddd5983d..b52b38b9 100644 --- a/src/Database/Statement.php +++ b/src/Database/Statement.php @@ -26,7 +26,7 @@ class Statement /** * The where constraints for the query. */ - protected array $wheres = []; + protected array $whereStatements = []; /** * Create a new Statement instance. @@ -68,11 +68,13 @@ public function toSql(): string * * @return $this */ - public function select(string $table, array $columns = ['*']): Statement + public function select(string $table, array $columns = null): Statement { - $columns = $this->implode($columns); + $columns = \is_null($columns) + ? '*' + : Helpers::implode($columns, Helpers::BACKTICKED_MASK); - $this->query[] = "select $columns from $table"; + $this->query[] = "SELECT $columns FROM `$table`"; return $this; } @@ -84,11 +86,11 @@ public function select(string $table, array $columns = ['*']): Statement */ public function insert(string $table, array $columns): Statement { - $values = $this->implode($columns, ':{column}'); + $values = Helpers::implode($columns, ':{column}'); - $columns = $this->implode($columns); + $columns = Helpers::implode($columns, Helpers::BACKTICKED_MASK); - $this->query[] = "insert into $table ($columns) values ($values)"; + $this->query[] = "INSERT INTO `$table` ($columns) values ($values)"; return $this; } @@ -100,9 +102,16 @@ public function insert(string $table, array $columns): Statement */ public function update(string $table, array $columns): Statement { - $columns = $this->implode($columns, '{column} = :{column}'); + $columns = Helpers::implode( + $columns, + \sprintf( + '%s = :%s', + Helpers::BACKTICKED_MASK, + Helpers::DEFAULT_MASK, + ) + ); - $this->query[] = "update $table set $columns"; + $this->query[] = "UPDATE `$table` SET $columns"; return $this; } @@ -114,7 +123,7 @@ public function update(string $table, array $columns): Statement */ public function delete(string $table): Statement { - $this->query[] = "delete from $table"; + $this->query[] = "DELETE FROM `$table`"; return $this; } @@ -127,9 +136,11 @@ public function delete(string $table): Statement public function where(array $columns): Statement { foreach ($columns as $column) { - $this->wheres[] = [ - 'type' => 'Where', 'column' => $column, 'operator' => '=', 'boolean' => 'and', - ]; + $this->whereStatements[] = new WhereStatement( + boolean: WhereBoolean::And, + operator: WhereOperator::Equal, + column: $column, + ); } return $this; @@ -140,45 +151,16 @@ public function where(array $columns): Statement */ protected function compileWheres(): void { - if ([] === $this->wheres) { + if ([] === $this->whereStatements) { return; } - $this->query[] = 'where'; + $this->query[] = 'WHERE'; - foreach ($this->wheres as $index => $condition) { - $method = 'compile' . $condition['type']; + foreach ($this->whereStatements as $index => $whereQuery) { + $result = $whereQuery->compile($index); - if (0 == $index) { - $condition['boolean'] = ''; - } - - $this->query[] = trim($this->{$method}($condition)); + $this->query[] = $result->output; } } - - /** - * Compile the basic where statement. - */ - protected function compileWhere(array $where): string - { - // This code is here to remove the use of the extract() method in the original repo. See the git history. - $boolean = array_key_exists('boolean', $where) ? $where['boolean'] : null; - $column = array_key_exists('column', $where) ? $where['column'] : null; - $operator = array_key_exists('operator', $where) ? $where['operator'] : null; - - return "$boolean $column $operator :$column"; - } - - /** - * Join array elements using a string mask. - */ - protected function implode(array $columns, string $mask = '{column}'): string - { - $columns = array_map(function ($column) use ($mask): string { - return str_replace('{column}', $column, $mask); - }, $columns); - - return implode(', ', $columns); - } } diff --git a/src/Database/WhereBoolean.php b/src/Database/WhereBoolean.php new file mode 100644 index 00000000..9b76058a --- /dev/null +++ b/src/Database/WhereBoolean.php @@ -0,0 +1,18 @@ + + * @copyright Copyright (c) Wizacha + * @copyright Copyright (c) Leonardo Marquine + * @license MIT + */ + +declare(strict_types=1); + +namespace Wizaplace\Etl\Database; + +enum WhereBoolean: string +{ + case And = 'AND'; + case Nothing = ''; +} diff --git a/src/Database/WhereCompileResult.php b/src/Database/WhereCompileResult.php new file mode 100644 index 00000000..25565de2 --- /dev/null +++ b/src/Database/WhereCompileResult.php @@ -0,0 +1,21 @@ + + * @copyright Copyright (c) Wizacha + * @copyright Copyright (c) Leonardo Marquine + * @license MIT + */ + +declare(strict_types=1); + +namespace Wizaplace\Etl\Database; + +class WhereCompileResult +{ + public function __construct( + public string $output, + public array $bindings, + ) { + } +} diff --git a/src/Database/WhereInCompositeQuery.php b/src/Database/WhereInCompositeQuery.php new file mode 100644 index 00000000..0f613454 --- /dev/null +++ b/src/Database/WhereInCompositeQuery.php @@ -0,0 +1,58 @@ + + * @copyright Copyright (c) Wizacha + * @copyright Copyright (c) Leonardo Marquine + * @license MIT + */ + +declare(strict_types=1); + +namespace Wizaplace\Etl\Database; + +class WhereInCompositeQuery implements WhereInterface +{ + public function __construct( + private WhereBoolean $boolean, + private WhereOperator $operator, + private array $multipleColumns, + private array $multipleValues, + ) { + } + + public function compile(int $index): WhereCompileResult + { + sort($this->multipleColumns); + + $parameters = []; + $bindings = []; + foreach ($this->multipleValues as $value) { + ksort($value); + + $bindings = array_merge($bindings, array_values($value)); + + $parameters[] = \sprintf( + '(%s)', + Helpers::implode($value, '?') + ); + } + + $parameters = Helpers::implode($parameters); + + $multipleColumns = Helpers::implode($this->multipleColumns, Helpers::BACKTICKED_MASK); + + return new WhereCompileResult( + \trim( + \sprintf( + '%s (%s) %s (%s)', + $index > 0 ? $this->boolean->value : '', + $multipleColumns, + $this->operator->value, + $parameters, + ) + ), + $bindings + ); + } +} diff --git a/src/Database/WhereInQuery.php b/src/Database/WhereInQuery.php new file mode 100644 index 00000000..0aadc3bd --- /dev/null +++ b/src/Database/WhereInQuery.php @@ -0,0 +1,41 @@ + + * @copyright Copyright (c) Wizacha + * @copyright Copyright (c) Leonardo Marquine + * @license MIT + */ + +declare(strict_types=1); + +namespace Wizaplace\Etl\Database; + +class WhereInQuery implements WhereInterface +{ + public function __construct( + private WhereBoolean $boolean, + private WhereOperator $operator, + private string $column, + private array $multipleValues, + ) { + } + + public function compile(int $index): WhereCompileResult + { + $parameters = Helpers::implode($this->multipleValues, '?'); + + return new WhereCompileResult( + \trim( + \sprintf( + '%s `%s` %s (%s)', + $index > 0 ? $this->boolean->value : '', + $this->column, + $this->operator->value, + $parameters, + ) + ), + $this->multipleValues, + ); + } +} diff --git a/src/Database/WhereInterface.php b/src/Database/WhereInterface.php new file mode 100644 index 00000000..e5c0e17d --- /dev/null +++ b/src/Database/WhereInterface.php @@ -0,0 +1,17 @@ + + * @copyright Copyright (c) Wizacha + * @copyright Copyright (c) Leonardo Marquine + * @license MIT + */ + +declare(strict_types=1); + +namespace Wizaplace\Etl\Database; + +interface WhereInterface +{ + public function compile(int $index): WhereCompileResult; +} diff --git a/src/Database/WhereOperator.php b/src/Database/WhereOperator.php new file mode 100644 index 00000000..fd66ca8d --- /dev/null +++ b/src/Database/WhereOperator.php @@ -0,0 +1,25 @@ + + * @copyright Copyright (c) Wizacha + * @copyright Copyright (c) Leonardo Marquine + * @license MIT + */ + +declare(strict_types=1); + +namespace Wizaplace\Etl\Database; + +enum WhereOperator: string +{ + case Equal = '='; + case Less = '<'; + case LessOrEqual = '<='; + case Greater = '>'; + case GreaterOrEqual = '>='; + case NotEqual = '<>'; + case NotEqualBis = '!='; + case In = 'IN'; + case NotIn = 'NOT IN'; +} diff --git a/src/Database/WhereQuery.php b/src/Database/WhereQuery.php new file mode 100644 index 00000000..44d65ff8 --- /dev/null +++ b/src/Database/WhereQuery.php @@ -0,0 +1,38 @@ + + * @copyright Copyright (c) Wizacha + * @copyright Copyright (c) Leonardo Marquine + * @license MIT + */ + +declare(strict_types=1); + +namespace Wizaplace\Etl\Database; + +class WhereQuery implements WhereInterface +{ + public function __construct( + private WhereBoolean $boolean, + private WhereOperator $operator, + private string $column, + private mixed $value, + ) { + } + + public function compile(int $index): WhereCompileResult + { + return new WhereCompileResult( + \trim( + \sprintf( + '%s `%s` %s ?', + $index > 0 ? $this->boolean->value : '', + $this->column, + $this->operator->value, + ) + ), + [$this->value], + ); + } +} diff --git a/src/Database/WhereStatement.php b/src/Database/WhereStatement.php new file mode 100644 index 00000000..5d9270ae --- /dev/null +++ b/src/Database/WhereStatement.php @@ -0,0 +1,38 @@ + + * @copyright Copyright (c) Wizacha + * @copyright Copyright (c) Leonardo Marquine + * @license MIT + */ + +declare(strict_types=1); + +namespace Wizaplace\Etl\Database; + +class WhereStatement implements WhereInterface +{ + public function __construct( + private WhereBoolean $boolean, + private WhereOperator $operator, + private string $column, + ) { + } + + public function compile(int $index): WhereCompileResult + { + return new WhereCompileResult( + \trim( + \sprintf( + '%s `%s` %s :%s', + $index > 0 ? $this->boolean->value : '', + $this->column, + $this->operator->value, + $this->column, + ) + ), + [], + ); + } +} diff --git a/src/Etl.php b/src/Etl.php index 9ebabfab..f0727e95 100644 --- a/src/Etl.php +++ b/src/Etl.php @@ -28,7 +28,7 @@ class Etl /** * Create a new Etl instance. */ - public function __construct(?Pipeline $pipeline = null) + public function __construct(Pipeline $pipeline = null) { $this->pipeline = $pipeline ?? new Pipeline(); } @@ -40,12 +40,11 @@ public function __construct(?Pipeline $pipeline = null) * Etl\Extractor\Csv needs a string * Etl\Extractor\Collection an \Iterator * - * @param mixed $input * @param array $options * * @return $this */ - public function extract(Extractor $extractor, $input, $options = []): Etl + public function extract(Extractor $extractor, mixed $input, $options = []): Etl { $extractor->input($input)->options($options); diff --git a/src/Extractors/DateDimension.php b/src/Extractors/DateDimension.php index 4e227f61..6a91c3e9 100644 --- a/src/Extractors/DateDimension.php +++ b/src/Extractors/DateDimension.php @@ -75,12 +75,12 @@ public function __construct() $this->startDate ??= $this->getCenterDateTime() ->sub($defaultBoundInterval) - ->format(static::CENTER_DATE_FORMAT); + ->format(self::CENTER_DATE_FORMAT); $this->endDate ??= $this->getCenterDateTime() ->add($defaultBoundInterval) ->sub($dayInterval) - ->format(static::CENTER_DATE_FORMAT); + ->format(self::CENTER_DATE_FORMAT); } /** diff --git a/src/Extractors/Extractor.php b/src/Extractors/Extractor.php index 3934b226..e26a3cc0 100644 --- a/src/Extractors/Extractor.php +++ b/src/Extractors/Extractor.php @@ -15,17 +15,14 @@ abstract class Extractor extends Step { - /** @var mixed */ - protected $input; + protected mixed $input; /** * Set the extractor input. * - * @param mixed $input - * * @return $this */ - public function input($input): Extractor + public function input(mixed $input): Extractor { $this->input = $input; diff --git a/src/Extractors/Xml.php b/src/Extractors/Xml.php index 881d144d..4584d4f7 100644 --- a/src/Extractors/Xml.php +++ b/src/Extractors/Xml.php @@ -58,9 +58,7 @@ public function extract(): \Generator $value = $this->loop . $value; } - $this->reader = new \XMLReader(); - - $this->reader->open($this->input); + $this->reader = \XMLReader::open($this->input); while ($this->reader->read()) { $this->addElementToPath(); diff --git a/src/Loaders/InsertUpdate.php b/src/Loaders/InsertUpdate.php index 68c79736..0936dd4c 100644 --- a/src/Loaders/InsertUpdate.php +++ b/src/Loaders/InsertUpdate.php @@ -24,9 +24,9 @@ class InsertUpdate extends Insert /** * The primary key. * - * @var mixed + * @var string[] */ - protected $key = ['id']; + protected array $key = ['id']; /** * Indicates if existing destination rows in table should be updated. @@ -38,14 +38,14 @@ class InsertUpdate extends Insert * * @var \PDOStatement|false|null */ - protected $select = null; + protected $select; /** * The update statement. * * @var \PDOStatement|false|null */ - protected $update = null; + protected $update; /** * Properties that can be set via the options method. diff --git a/src/Loaders/Loader.php b/src/Loaders/Loader.php index 8bea81e2..cca9ec0d 100644 --- a/src/Loaders/Loader.php +++ b/src/Loaders/Loader.php @@ -18,19 +18,15 @@ abstract class Loader extends Step { /** * The loader output. - * - * @var mixed */ - protected $output; + protected mixed $output; /** * Set the loader output. * - * @param mixed $output - * * @return $this */ - public function output($output): Loader + public function output(mixed $output): Loader { $this->output = $output; diff --git a/src/Row.php b/src/Row.php index 89d4e7bf..a474b55e 100644 --- a/src/Row.php +++ b/src/Row.php @@ -38,10 +38,8 @@ public function __construct(array $attributes) /** * Set a row attribute. - * - * @param mixed $value */ - public function set(string $key, $value): self + public function set(string $key, mixed $value): self { $this->attributes[$key] = $value; @@ -50,10 +48,8 @@ public function set(string $key, $value): self /** * Get a row attribute. - * - * @return mixed */ - public function get(string $key) + public function get(string $key): mixed { return $this->attributes[$key] ?? null; } @@ -68,10 +64,8 @@ public function remove(string $key): void /** * Get a row attribute, and remove it. - * - * @return mixed */ - public function pull(string $key) + public function pull(string $key): mixed { $value = $this->attributes[$key] ?? null; @@ -92,7 +86,7 @@ public function transform(array $columns, callable $callback): void } foreach ($columns as $column) { - $this->$column = $callback($this->$column); + $this->attributes[$column] = $callback($this->attributes[$column]); } } @@ -142,28 +136,22 @@ public function isIncomplete(): bool /** * Dynamically retrieve attributes on the row. - * - * @return mixed */ - public function __get(string $key) + public function __get(string $key): mixed { return $this->attributes[$key]; } /** * Dynamically set attributes on the row. - * - * @param mixed $value */ - public function __set(string $key, $value): void + public function __set(string $key, mixed $value): void { $this->attributes[$key] = $value; } /** * Determine if the given attribute exists. - * - * @param mixed $offset */ public function offsetExists($offset): bool { @@ -172,8 +160,6 @@ public function offsetExists($offset): bool /** * Get the value for a given offset. - * - * @param mixed $offset */ public function offsetGet($offset): mixed { @@ -182,9 +168,6 @@ public function offsetGet($offset): mixed /** * Set the value for a given offset. - * - * @param mixed $offset - * @param mixed $value */ public function offsetSet($offset, $value): void { @@ -193,8 +176,6 @@ public function offsetSet($offset, $value): void /** * Unset the value for a given offset. - * - * @param mixed $offset */ public function offsetUnset($offset): void { diff --git a/src/Transformers/UniqueRows.php b/src/Transformers/UniqueRows.php index c70cac1e..e0848fc7 100644 --- a/src/Transformers/UniqueRows.php +++ b/src/Transformers/UniqueRows.php @@ -70,9 +70,9 @@ public function transform(Row $row): void /** * Prepare the given row for comparison. * - * @return mixed + * @return Row|string */ - protected function prepare(Row $row) + protected function prepare(Row $row): mixed { $row = $row->toArray(); @@ -80,26 +80,28 @@ protected function prepare(Row $row) $row = array_intersect_key($row, array_flip($this->columns)); } - return $this->consecutive ? $row : md5(serialize($row)); + return $this->consecutive + ? $row + : md5(serialize($row)); } /** * Verify if the subject is duplicate. - * - * @param mixed $subject */ - protected function isDuplicate($subject): bool + protected function isDuplicate(mixed $subject): bool { - return $this->consecutive ? $subject === $this->control : in_array($subject, $this->hashTable, true); + return $this->consecutive + ? $subject === $this->control + : in_array($subject, $this->hashTable, true); } /** * Register the subject for future comparison. - * - * @param mixed $subject */ - protected function register($subject): void + protected function register(mixed $subject): void { - $this->consecutive ? $this->control = $subject : $this->hashTable[] = $subject; + $this->consecutive + ? $this->control = $subject + : $this->hashTable[] = $subject; } } diff --git a/tests/Unit/Database/HelpersTest.php b/tests/Unit/Database/HelpersTest.php new file mode 100644 index 00000000..bcd4c18f --- /dev/null +++ b/tests/Unit/Database/HelpersTest.php @@ -0,0 +1,67 @@ + [ + [ + ['*'], + '`{column}`', + ], + '*', + ], + 'do not backtick * in a list' => [ + [ + ['*', 'other'], + '`{column}`', + ], + '*, `other`', + ], + 'backtick * if not in ignoreMask' => [ + [ + ['*', 'other'], + '`{column}`', + ['other'], + ], + '`*`, other', + ], + 'common' => [ + [ + ['hello', 'world'], + '#{column}#', + ], + '#hello#, #world#', + ], + 'default' => [ + [ + ['hello', 'world'], + ], + 'hello, world', + ], + ]; + } +} diff --git a/tests/Unit/Database/QueryTest.php b/tests/Unit/Database/QueryTest.php index 618dcc02..729e935d 100644 --- a/tests/Unit/Database/QueryTest.php +++ b/tests/Unit/Database/QueryTest.php @@ -22,12 +22,12 @@ public function select(): void $query = new Query($this->createMock('PDO')); $query->select('users'); - static::assertEquals('select * from users', $query->toSql()); + static::assertEquals('SELECT * FROM `users`', $query->toSql()); $query = new Query($this->createMock('PDO')); $query->select('users', ['name', 'email']); - static::assertEquals('select name, email from users', $query->toSql()); + static::assertEquals('SELECT `name`, `email` FROM `users`', $query->toSql()); } /** @test */ @@ -36,7 +36,7 @@ public function insert(): void $query = new Query($this->createMock('PDO')); $query->insert('users', ['name' => 'Jane Doe', 'email' => 'janedoe@example.com']); - static::assertEquals('insert into users (name, email) values (?, ?)', $query->toSql()); + static::assertEquals('INSERT INTO `users` (`name`, `email`) VALUES (?, ?)', $query->toSql()); static::assertEquals(['Jane Doe', 'janedoe@example.com'], $query->getBindings()); } @@ -46,7 +46,7 @@ public function update(): void $query = new Query($this->createMock('PDO')); $query->update('users', ['name' => 'Jane Doe', 'email' => 'janedoe@example.com']); - static::assertEquals('update users set name = ?, email = ?', $query->toSql()); + static::assertEquals('UPDATE `users` SET `name` = ?, `email` = ?', $query->toSql()); static::assertEquals(['Jane Doe', 'janedoe@example.com'], $query->getBindings()); } @@ -56,7 +56,7 @@ public function delete(): void $query = new Query($this->createMock('PDO')); $query->delete('users'); - static::assertEquals('delete from users', $query->toSql()); + static::assertEquals('DELETE FROM `users`', $query->toSql()); static::assertEquals([], $query->getBindings()); } @@ -66,7 +66,7 @@ public function where(): void $query = new Query($this->createMock('PDO')); $query->where(['name' => 'Jane Doe', 'email' => 'janedoe@example.com']); - static::assertEquals('where name = ? and email = ?', $query->toSql()); + static::assertEquals('WHERE `name` = ? AND `email` = ?', $query->toSql()); static::assertEquals(['Jane Doe', 'janedoe@example.com'], $query->getBindings()); } @@ -76,7 +76,7 @@ public function whereIn(): void $query = new Query($this->createMock('PDO')); $query->whereIn('id', ['1', '2']); - static::assertEquals('where id in (?, ?)', $query->toSql()); + static::assertEquals('WHERE `id` IN (?, ?)', $query->toSql()); static::assertEquals(['1', '2'], $query->getBindings()); } @@ -86,7 +86,7 @@ public function whereNotIn(): void $query = new Query($this->createMock('PDO')); $query->whereNotIn('id', ['1', '2']); - static::assertEquals('where id not in (?, ?)', $query->toSql()); + static::assertEquals('WHERE `id` NOT IN (?, ?)', $query->toSql()); static::assertEquals(['1', '2'], $query->getBindings()); } @@ -96,7 +96,7 @@ public function compositeWhereIn(): void $query = new Query($this->createMock('PDO')); $query->whereIn(['id', 'company'], [['id' => '1', 'company' => '1'], ['id' => '2', 'company' => '1']]); - static::assertEquals('where (company, id) in ((?, ?), (?, ?))', $query->toSql()); + static::assertEquals('WHERE (`company`, `id`) IN ((?, ?), (?, ?))', $query->toSql()); static::assertEquals(['1', '1', '1', '2'], $query->getBindings()); } @@ -106,7 +106,7 @@ public function compositeWhereNotIn(): void $query = new Query($this->createMock('PDO')); $query->whereNotIn(['id', 'company'], [['id' => '1', 'company' => '1'], ['id' => '2', 'company' => '1']]); - static::assertEquals('where (company, id) not in ((?, ?), (?, ?))', $query->toSql()); + static::assertEquals('WHERE (`company`, `id`) NOT IN ((?, ?), (?, ?))', $query->toSql()); static::assertEquals(['1', '1', '1', '2'], $query->getBindings()); } diff --git a/tests/Unit/Database/StatementTest.php b/tests/Unit/Database/StatementTest.php index c8b8018c..207cc156 100644 --- a/tests/Unit/Database/StatementTest.php +++ b/tests/Unit/Database/StatementTest.php @@ -24,12 +24,12 @@ public function select(): void $statement = new Statement($this->createMock('PDO')); $statement->select('users'); - static::assertEquals('select * from users', $statement->toSql()); + static::assertEquals('SELECT * FROM `users`', $statement->toSql()); $statement = new Statement($this->createMock('PDO')); $statement->select('users', ['name', 'email']); - static::assertEquals('select name, email from users', $statement->toSql()); + static::assertEquals('SELECT `name`, `email` FROM `users`', $statement->toSql()); } /** @test */ @@ -38,7 +38,7 @@ public function insert(): void $statement = new Statement($this->createMock('PDO')); $statement->insert('users', ['name', 'email']); - static::assertEquals('insert into users (name, email) values (:name, :email)', $statement->toSql()); + static::assertEquals('INSERT INTO `users` (`name`, `email`) values (:name, :email)', $statement->toSql()); } /** @test */ @@ -47,7 +47,7 @@ public function update(): void $statement = new Statement($this->createMock('PDO')); $statement->update('users', ['name', 'email']); - static::assertEquals('update users set name = :name, email = :email', $statement->toSql()); + static::assertEquals('UPDATE `users` SET `name` = :name, `email` = :email', $statement->toSql()); } /** @test */ @@ -56,7 +56,7 @@ public function delete(): void $statement = new Statement($this->createMock('PDO')); $statement->delete('users'); - static::assertEquals('delete from users', $statement->toSql()); + static::assertEquals('DELETE FROM `users`', $statement->toSql()); } /** @test */ @@ -65,7 +65,7 @@ public function where(): void $statement = new Statement($this->createMock('PDO')); $statement->where(['name', 'email']); - static::assertEquals('where name = :name and email = :email', $statement->toSql()); + static::assertEquals('WHERE `name` = :name AND `email` = :email', $statement->toSql()); } /** @test */ @@ -98,13 +98,9 @@ public function prepareInvalid(): void $statement = new Statement($database); $statement->select('foo', ['>']); - try { - $statement->prepare(); - static::fail('An exception should have been thrown'); - } catch (\PDOException $exception) { - static::assertEquals('SQLSTATE[HY000]: General error: 1 near ">": syntax error', $exception->getMessage()); - } catch (\Exception $exception) { - static::fail('An instance of ' . \PDOException::class . ' should have been thrown'); - } + $this->expectExceptionObject( + new \PDOException('SQLSTATE[HY000]: General error: 1 no such table: foo') + ); + $statement->prepare(); } } diff --git a/tests/Unit/Extractors/AggregatorTest.php b/tests/Unit/Extractors/AggregatorTest.php index 1b01f632..48625cff 100644 --- a/tests/Unit/Extractors/AggregatorTest.php +++ b/tests/Unit/Extractors/AggregatorTest.php @@ -230,7 +230,7 @@ public static function iteratorsProvider(): array $simpleDataSet = [ [ - static::arrayToIterator([ + self::arrayToIterator([ ['id' => 1, 'name' => 'John Doe', 'email' => 'johndoe@email.com'], [], // should not happen ['impossible error'], // should not happen as well @@ -238,7 +238,7 @@ public static function iteratorsProvider(): array ['id' => 3, 'name' => 'Incomplete1', 'email' => 'incomplete1@dirtydata'], ['id' => 4, 'name' => 'Incomplete2', 'email' => 'incomplete2@dirtydata'], ]), - static::arrayToIterator([ + self::arrayToIterator([ ['email' => 'janedoe@email.com', 'twitter' => '@jane'], ['email' => 'johndoe@email.com', 'twitter' => '@john'], ['impossible error'], // should not happen as well diff --git a/tests/Unit/Extractors/DateDimensionTest.php b/tests/Unit/Extractors/DateDimensionTest.php index f6256a0e..b3d30d0b 100644 --- a/tests/Unit/Extractors/DateDimensionTest.php +++ b/tests/Unit/Extractors/DateDimensionTest.php @@ -205,7 +205,7 @@ public function defaultStart(): void $year = (int) $firstDay->format('Y'); $result = \iterator_to_array($extractor->extract()); - $firstRow = reset($result[0]); + $firstRow = reset($result); $lastRow = end($result); static::assertStringMatchesFormat( @@ -332,7 +332,7 @@ private function iterateDimensions(DateDimension $extractor): array $currentDayTimestamp = (new \DateTimeImmutable($date[DateDimension::ROW_DATE_FULL]))->getTimestamp(); if (null !== $previousDayTimestamp) { - $delta = $currentDayTimestamp - $previousDayTimestamp - static::DAY_AS_SECONDS; + $delta = $currentDayTimestamp - $previousDayTimestamp - self::DAY_AS_SECONDS; } if ($delta > 0) { diff --git a/tests/Unit/Extractors/QueryTest.php b/tests/Unit/Extractors/QueryTest.php index f89bdcbe..340c9b41 100644 --- a/tests/Unit/Extractors/QueryTest.php +++ b/tests/Unit/Extractors/QueryTest.php @@ -11,7 +11,9 @@ namespace Tests\Unit\Extractors; +use PHPUnit\Framework\MockObject\MockObject; use Tests\Tools\AbstractTestCase; +use Wizaplace\Etl\Database\Manager; use Wizaplace\Etl\Extractors\Query; use Wizaplace\Etl\Row; @@ -20,16 +22,32 @@ class QueryTest extends AbstractTestCase /** @test */ public function defaultOptions(): void { - $statement = $this->createMock('PDOStatement'); - $statement->expects(static::once())->method('execute')->with([]); - $statement->expects(static::exactly(3))->method('fetch') - ->will(static::onConsecutiveCalls(['row1'], ['row2'], null)); - - $connection = $this->createMock('PDO'); - $connection->expects(static::once())->method('prepare')->with('select query')->willReturn($statement); - - $manager = $this->createMock('Wizaplace\Etl\Database\Manager'); - $manager->expects(static::once())->method('pdo')->with('default')->willReturn($connection); + /** @var MockObject|\PDOStatement */ + $statement = $this->createMock(\PDOStatement::class); + $statement + ->expects(static::once()) + ->method('execute') + ->with([]); + $statement + ->expects(static::exactly(3)) + ->method('fetch') + ->willReturn(['row1'], ['row2'], null); + + /** @var MockObject|\PDO */ + $connection = $this->createMock(\PDO::class); + $connection + ->expects(static::once()) + ->method('prepare') + ->with('select query') + ->willReturn($statement); + + /** @var MockObject|Manager */ + $manager = $this->createMock(Manager::class); + $manager + ->expects(static::once()) + ->method('pdo') + ->with('default') + ->willReturn($connection); $extractor = new Query($manager); @@ -41,16 +59,32 @@ public function defaultOptions(): void /** @test */ public function customConnectionAndBindings(): void { - $statement = $this->createMock('PDOStatement'); - $statement->expects(static::once())->method('execute')->with(['binding']); - $statement->expects(static::exactly(3))->method('fetch') - ->will(static::onConsecutiveCalls(['row1'], ['row2'], null)); - - $connection = $this->createMock('PDO'); - $connection->expects(static::once())->method('prepare')->with('select query')->willReturn($statement); - - $manager = $this->createMock('Wizaplace\Etl\Database\Manager'); - $manager->expects(static::once())->method('pdo')->with('connection')->willReturn($connection); + /** @var MockObject|\PDOStatement */ + $statement = $this->createMock(\PDOStatement::class); + $statement + ->expects(static::once()) + ->method('execute') + ->with(['binding']); + $statement + ->expects(static::exactly(3)) + ->method('fetch') + ->willReturn(['row1'], ['row2'], null); + + /** @var MockObject|\PDO */ + $connection = $this->createMock(\PDO::class); + $connection + ->expects(static::once()) + ->method('prepare') + ->with('select query') + ->willReturn($statement); + + /** @var MockObject|Manager */ + $manager = $this->createMock(Manager::class); + $manager + ->expects(static::once()) + ->method('pdo') + ->with('connection') + ->willReturn($connection); $extractor = new Query($manager); diff --git a/tests/Unit/Extractors/TableTest.php b/tests/Unit/Extractors/TableTest.php index 631e9afd..0fe184d2 100644 --- a/tests/Unit/Extractors/TableTest.php +++ b/tests/Unit/Extractors/TableTest.php @@ -11,10 +11,12 @@ namespace Tests\Unit\Extractors; +use PHPUnit\Framework\MockObject\MockObject; use Tests\Tools\AbstractTestCase; use Wizaplace\Etl; use Wizaplace\Etl\Database\ConnectionFactory; use Wizaplace\Etl\Database\Manager; +use Wizaplace\Etl\Database\Query; use Wizaplace\Etl\Extractors\Table; use Wizaplace\Etl\Row; @@ -23,17 +25,37 @@ class TableTest extends AbstractTestCase /** @test */ public function defaultOptions(): void { - $statement = $this->createMock('PDOStatement'); - $statement->expects(static::exactly(3))->method('fetch') - ->will(static::onConsecutiveCalls(['row1'], ['row2'], null)); - - $query = $this->createMock('Wizaplace\Etl\Database\Query'); - $query->expects(static::once())->method('select')->with('table', ['*'])->will(static::returnSelf()); - $query->expects(static::once())->method('where')->with([])->will(static::returnSelf()); - $query->expects(static::once())->method('execute')->willReturn($statement); - - $manager = $this->createMock('Wizaplace\Etl\Database\Manager'); - $manager->expects(static::once())->method('query')->with('default')->willReturn($query); + /** @var MockObject|\PDOStatement */ + $statement = $this->createMock(\PDOStatement::class); + $statement + ->expects(static::exactly(3)) + ->method('fetch') + ->willReturn(['row1'], ['row2'], null); + + /** @var MockObject|Query */ + $query = $this->createMock(Query::class); + $query + ->expects(static::once()) + ->method('select') + ->with('table', ['*']) + ->willReturnSelf(); + $query + ->expects(static::once()) + ->method('where') + ->with([]) + ->willReturnSelf(); + $query + ->expects(static::once()) + ->method('execute') + ->willReturn($statement); + + /** @var MockObject|Manager */ + $manager = $this->createMock(Manager::class); + $manager + ->expects(static::once()) + ->method('query') + ->with('default') + ->willReturn($query); $extractor = new Table($manager); @@ -45,17 +67,38 @@ public function defaultOptions(): void /** @test */ public function customConnectionColumnsAndWhereClause(): void { - $statement = $this->createMock('PDOStatement'); - $statement->expects(static::exactly(3))->method('fetch') - ->will(static::onConsecutiveCalls(['row1'], ['row2'], null)); - - $query = $this->createMock('Wizaplace\Etl\Database\Query'); - $query->expects(static::once())->method('select')->with('table', ['columns'])->will(static::returnSelf()); - $query->expects(static::once())->method('where')->with(['where'])->will(static::returnSelf()); - $query->expects(static::once())->method('execute')->willReturn($statement); - - $manager = $this->createMock('Wizaplace\Etl\Database\Manager'); - $manager->expects(static::once())->method('query')->with('connection')->willReturn($query); + /** @var MockObject|\PDOStatement */ + $statement = $this + ->createMock(\PDOStatement::class); + $statement + ->expects(static::exactly(3)) + ->method('fetch') + ->willReturn(['row1'], ['row2'], null); + + /** @var MockObject|Query */ + $query = $this->createMock(Query::class); + $query + ->expects(static::once()) + ->method('select') + ->with('table', ['columns']) + ->willReturnSelf(); + $query + ->expects(static::once()) + ->method('where') + ->with(['where']) + ->willReturnSelf(); + $query + ->expects(static::once()) + ->method('execute') + ->willReturn($statement); + + /** @var MockObject|Manager */ + $manager = $this->createMock(Manager::class); + $manager + ->expects(static::once()) + ->method('query') + ->with('connection') + ->willReturn($query); $extractor = new Table($manager);