diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26d00f0..c127b53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,11 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['7.4', '8.0', '8.3'] + php-version: ['8.1', '8.2', '8.3'] db-type: ['mysql', 'pgsql'] prefer-lowest: [''] include: - - php-version: '7.4' + - php-version: '8.1' db-type: 'sqlite' prefer-lowest: 'prefer-lowest' @@ -61,14 +61,14 @@ jobs: if [[ ${{ matrix.db-type }} == 'mysql' ]]; then export DB_URL='mysql://root:root@127.0.0.1/cakephp'; fi if [[ ${{ matrix.db-type }} == 'pgsql' ]]; then export DB_URL='postgres://postgres:postgres@127.0.0.1/postgres'; fi - if [[ ${{ matrix.php-version }} == '7.4' && ${{ matrix.db-type }} == 'mysql' ]]; then - vendor/bin/phpunit --coverage-clover=coverage.xml + if [[ ${{ matrix.php-version }} == '8.1' && ${{ matrix.db-type }} == 'mysql' ]]; then + vendor/bin/phpunit tests/TestCase --coverage-clover=coverage.xml else - vendor/bin/phpunit + vendor/bin/phpunit tests/TestCase fi - name: Code Coverage Report - if: success() && matrix.php-version == '7.4' && matrix.db-type == 'mysql' + if: success() && matrix.php-version == '8.1' && matrix.db-type == 'mysql' uses: codecov/codecov-action@v1 cs-stan: @@ -81,10 +81,10 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '7.4' + php-version: '8.1' extensions: mbstring, intl coverage: none - tools: psalm:4, phpstan:1 + tools: psalm:5.23.1, phpstan:1.10.65 - name: Composer Install run: composer require cakephp/cakephp-codesniffer:^4.2 diff --git a/.gitignore b/.gitignore index 654564b..6f0931b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ /plugins /vendor .phpunit.result.cache +/.idea +*.diff +*.cache diff --git a/composer.json b/composer.json index 1bee8e4..5361dbb 100644 --- a/composer.json +++ b/composer.json @@ -39,12 +39,19 @@ "irc": "irc://irc.freenode.org/muffin" }, "require": { - "cakephp/orm": "^4.5" + "cakephp/orm": "^5.0" }, "require-dev": { - "cakephp/cakephp": "^4.5", - "cakephp/cakephp-codesniffer": "^4.2", - "phpunit/phpunit": "^9.3" + "cakephp/cakephp": "^5.0", + "cakephp/cakephp-codesniffer": "^5.0", + "phpunit/phpunit": "^10.5.5", + "ext-mbstring": "*", + "vimeo/psalm": "5.23.1", + "phpstan/phpstan": "1.10.65" + }, + "scripts": { + "cs-check": "phpcs --colors --parallel=16 -p src/ tests/", + "cs-fix": "phpcbf --colors --parallel=16 -p src/ tests/" }, "autoload": { "psr-4": { diff --git a/phpstan.neon b/phpstan.neon index 3c5f6df..12e8698 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,21 +1,7 @@ parameters: - level: 6 + level: 8 checkMissingIterableValueType: false checkGenericClassInNonGenericObjectType: false + treatPhpDocTypesAsCertain: false paths: - src/ - ignoreErrors: - - - message: "#^Call to an undefined method Cake\\\\Datasource\\\\RepositoryInterface\\:\\:callFinder\\(\\)\\.$#" - count: 1 - path: src/Datasource/Query.php - - - - message: "#^Call to an undefined method Cake\\\\Datasource\\\\RepositoryInterface\\:\\:getName\\(\\)\\.$#" - count: 1 - path: src/Datasource/Query.php - - - - message: "#^Method Muffin\\\\Webservice\\\\Datasource\\\\Query\\:\\:execute\\(\\) should return bool\\|int\\|Muffin\\\\Webservice\\\\Datasource\\\\ResultSet\\|Muffin\\\\Webservice\\\\Model\\\\Resource but returns Cake\\\\Datasource\\\\ResultSetInterface\\.$#" - count: 1 - path: src/Datasource/Query.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f92600e..4891fcf 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,23 +1,20 @@ - - + ./tests/ - - + - - - + + + + + + ./src/ - - + + diff --git a/psalm.xml b/psalm.xml index edd7691..6f36d46 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,9 +1,12 @@ @@ -13,16 +16,9 @@ - - - - - - - - - - - + + + + diff --git a/src/Datasource/Connection.php b/src/Datasource/Connection.php index f31bc3c..63a161b 100644 --- a/src/Datasource/Connection.php +++ b/src/Datasource/Connection.php @@ -4,26 +4,33 @@ namespace Muffin\Webservice\Datasource; use Cake\Core\App; +use Cake\Datasource\ConnectionInterface; use Muffin\Webservice\Datasource\Exception\MissingConnectionException; use Muffin\Webservice\Webservice\Driver\AbstractDriver; use Muffin\Webservice\Webservice\Exception\MissingDriverException; -use Muffin\Webservice\Webservice\Exception\UnexpectedDriverException; +use Psr\SimpleCache\CacheInterface; /** * Class Connection * * @method \Muffin\Webservice\Webservice\Driver\AbstractDriver setWebservice(string $name, \Muffin\Webservice\Webservice\WebserviceInterface $webservice) Proxy method through to the Driver * @method \Muffin\Webservice\Webservice\WebserviceInterface getWebservice(string $name) Proxy method through to the Driver - * @method string configName() Proxy method through to the Driver */ -class Connection +class Connection implements ConnectionInterface { /** * Driver * * @var \Muffin\Webservice\Webservice\Driver\AbstractDriver */ - protected $_driver; + protected AbstractDriver $_driver; + + protected CacheInterface $cacher; + + /** + * The connection name in the connection manager. + */ + protected string $configName = ''; /** * Constructor @@ -33,17 +40,70 @@ class Connection */ public function __construct(array $config) { + if (isset($config['name'])) { + $this->configName = $config['name']; + } $config = $this->_normalizeConfig($config); - /** @psalm-var class-string<\Muffin\Webservice\Webservice\Driver\AbstractDriver> */ $driver = $config['driver']; unset($config['driver'], $config['service']); - $this->_driver = new $driver($config); + $tempDriver = new $driver($config); - /** @psalm-suppress TypeDoesNotContainType */ - if (!($this->_driver instanceof AbstractDriver)) { - throw new UnexpectedDriverException(['driver' => $driver]); - } + assert( + $tempDriver instanceof AbstractDriver, + '`$config[\'driver\']` must be an instance of `' . AbstractDriver::class . '`.' + ); + $this->_driver = $tempDriver; + } + + /** + * @param \Psr\SimpleCache\CacheInterface $cacher The cacher instance to use for query caching. + * @return self + * @psalm-suppress LessSpecificImplementedReturnType + */ + public function setCacher(CacheInterface $cacher): ConnectionInterface + { + $this->cacher = $cacher; + + return $this; + } + + /** @return \Psr\SimpleCache\CacheInterface */ + public function getCacher(): CacheInterface + { + return $this->cacher; + } + + /** + * {@inheritDoc} + * + * @param string $role Parameter is not used + * @see \Cake\Datasource\ConnectionInterface::getDriver() + * @return \Muffin\Webservice\Webservice\Driver\AbstractDriver + */ + public function getDriver(string $role = self::ROLE_WRITE): AbstractDriver + { + return $this->_driver; + } + + /** + * Get the configuration name for this connection. + * + * @return string + */ + public function configName(): string + { + return $this->configName; + } + + /** + * Get the config data for this connection. + * + * @return array + */ + public function config(): array + { + return $this->_driver->getConfig(); } /** @@ -77,8 +137,9 @@ protected function _normalizeConfig(array $config): array * @param array $args Arguments to pass-through * @return mixed */ - public function __call($method, $args) + public function __call(string $method, array $args): mixed { + /* @phpstan-ignore-next-line This is supported behavior for now: https://www.php.net/manual/en/function.call-user-func-array.php (example 1) */ return call_user_func_array([$this->_driver, $method], $args); } } diff --git a/src/Datasource/Exception/MissingConnectionException.php b/src/Datasource/Exception/MissingConnectionException.php index ace3e6e..259bc33 100644 --- a/src/Datasource/Exception/MissingConnectionException.php +++ b/src/Datasource/Exception/MissingConnectionException.php @@ -12,5 +12,5 @@ class MissingConnectionException extends CakeException * * @var string */ - protected $_messageTemplate = 'No `%s` connection configured.'; + protected string $_messageTemplate = 'No `%s` connection configured.'; } diff --git a/src/Datasource/Marshaller.php b/src/Datasource/Marshaller.php index 071fe06..becb2fd 100644 --- a/src/Datasource/Marshaller.php +++ b/src/Datasource/Marshaller.php @@ -22,7 +22,7 @@ class Marshaller * * @var \Muffin\Webservice\Model\Endpoint */ - protected $_endpoint; + protected Endpoint $_endpoint; /** * Constructor. @@ -53,7 +53,6 @@ public function one(array $data, array $options = []): EntityInterface [$data, $options] = $this->_prepareDataAndOptions($data, $options); $primaryKey = (array)$this->_endpoint->getPrimaryKey(); - /** @psalm-var class-string<\Muffin\Webservice\Model\Resource> */ $resourceClass = $this->_endpoint->getResourceClass(); $entity = new $resourceClass(); $entity->setSource($this->_endpoint->getRegistryAlias()); @@ -121,7 +120,7 @@ protected function _validate(array $data, array $options, bool $isNew): array $validator = $options['validator']; } - if (!is_callable([$validator, 'errors'])) { + if (!is_callable([$validator, 'validate'])) { throw new RuntimeException(sprintf( '"validate" must be a boolean, a string or an object with method "errors()". Got %s instead.', gettype($options['validate']) @@ -165,7 +164,7 @@ protected function _prepareDataAndOptions(array $data, array $options): array * * @param array $data The data to hydrate. * @param array $options List of options - * @return \Cake\Datasource\EntityInterface[] An array of hydrated records. + * @return array<\Cake\Datasource\EntityInterface> An array of hydrated records. * @see \Muffin\Webservice\Model\Endpoint::newEntities() */ public function many(array $data, array $options = []): array @@ -264,9 +263,9 @@ public function merge(EntityInterface $entity, array $data, array $options = []) * data merged in * @param array $data list of arrays to be merged into the entities * @param array $options List of options. - * @return \Cake\Datasource\EntityInterface[] + * @return array<\Cake\Datasource\EntityInterface> */ - public function mergeMany($entities, array $data, array $options = []): array + public function mergeMany(iterable $entities, array $data, array $options = []): array { $primary = (array)$this->_endpoint->getPrimaryKey(); diff --git a/src/Datasource/Query.php b/src/Datasource/Query.php index 0417431..6260663 100644 --- a/src/Datasource/Query.php +++ b/src/Datasource/Query.php @@ -4,20 +4,37 @@ namespace Muffin\Webservice\Datasource; use ArrayObject; +use Cake\Collection\Iterator\MapReduce; +use Cake\Database\Expression\OrderByExpression; +use Cake\Database\Expression\QueryExpression; +use Cake\Database\ExpressionInterface; +use Cake\Database\TypeMap; +use Cake\Database\TypeMapTrait; use Cake\Datasource\Exception\RecordNotFoundException; +use Cake\Datasource\QueryCacher; use Cake\Datasource\QueryInterface; -use Cake\Datasource\QueryTrait; +use Cake\Datasource\RepositoryInterface; +use Cake\Datasource\ResultSetDecorator; use Cake\Datasource\ResultSetInterface; use Cake\Utility\Hash; +use Closure; use InvalidArgumentException; use IteratorAggregate; use JsonSerializable; use Muffin\Webservice\Model\Endpoint; +use Muffin\Webservice\Model\Resource; use Muffin\Webservice\Webservice\WebserviceInterface; - +use Traversable; +use UnexpectedValueException; + +/** + * @template TKey + * @template-covariant TValue + * @template-implements \IteratorAggregate + */ class Query implements IteratorAggregate, JsonSerializable, QueryInterface { - use QueryTrait; + use TypeMapTrait; public const ACTION_CREATE = 1; public const ACTION_READ = 2; @@ -50,7 +67,14 @@ class Query implements IteratorAggregate, JsonSerializable, QueryInterface * * @var bool */ - protected $_beforeFindFired = false; + protected bool $_beforeFindFired = false; + + /** + * Whether the query is standalone or the product of an eager load operation. + * + * @var bool + */ + protected bool $_eagerLoaded = false; /** * Indicates whether internal state of this query was changed, this is used to @@ -59,33 +83,71 @@ class Query implements IteratorAggregate, JsonSerializable, QueryInterface * * @var bool */ - protected $_dirty = false; + protected bool $_dirty = false; /** * Parts being used to in the query * * @var array */ - protected $_parts = [ + protected array $_parts = [ 'order' => [], 'set' => [], 'where' => [], 'select' => [], ]; + /** + * Holds any custom options passed using applyOptions that could not be processed + * by any method in this class. + * + * @var array + */ + protected array $_options = []; + /** * Instance of the webservice to use * * @var \Muffin\Webservice\Webservice\WebserviceInterface */ - protected $_webservice; + protected WebserviceInterface $_webservice; /** * The result from the webservice * - * @var bool|int|\Muffin\Webservice\Model\Resource|\Muffin\Webservice\Datasource\ResultSet + * @var Resource|\Cake\Datasource\ResultSetInterface|int|bool|null + */ + protected bool|int|Resource|ResultSetInterface|null $_results = null; + + /** + * Instance of a endpoint object this query is bound to + * + * @var \Muffin\Webservice\Model\Endpoint + */ + protected Endpoint $_endpoint; + + /** + * List of map-reduce routines that should be applied over the query + * result + * + * @var array + */ + protected array $_mapReduce = []; + + /** + * List of formatter classes or callbacks that will post-process the + * results when fetched + * + * @var array<\Closure> + */ + protected array $_formatters = []; + + /** + * A query cacher instance if this query has caching enabled. + * + * @var \Cake\Datasource\QueryCacher|null */ - protected $_result; + protected ?QueryCacher $_cache = null; /** * Construct the query @@ -99,12 +161,142 @@ public function __construct(WebserviceInterface $webservice, Endpoint $endpoint) $this->setEndpoint($endpoint); } + /** + * Executes this query and returns a results iterator. This function is required + * for implementing the IteratorAggregate interface and allows the query to be + * iterated without having to call execute() manually, thus making it look like + * a result set instead of the query itself. + * + * @return \Traversable + */ + public function getIterator(): Traversable + { + return $this->all(); + } + + /** + * @inheritDoc + */ + public function aliasFields(array $fields, ?string $defaultAlias = null): array + { + $aliased = []; + foreach ($fields as $alias => $field) { + if (is_numeric($alias) && is_string($field)) { + $aliased += $this->aliasField($field, $defaultAlias); + continue; + } + $aliased[$alias] = $field; + } + + return $aliased; + } + + /** + * Fetch the results for this query. + * + * Will return either the results set through setResult(), or execute this query + * and return the ResultSetDecorator object ready for streaming of results. + * + * ResultSetDecorator is a traversable object that implements the methods found + * on Cake\Collection\Collection. + * + * @return \Cake\Datasource\ResultSetInterface + */ + public function all(): ResultSetInterface + { + if (is_iterable($this->_results)) { + $this->_results = $this->decorateResults($this->_results); + + return $this->_results; + } + + /** @psalm-suppress InternalMethod Could not find a better way apart from implementing it as a custom class **/ + $results = $this->_cache?->fetch($this); + if ($results === null) { + $res = $this->_execute(); + + if (!is_iterable($res)) { + $this->_results = new ResultSet([], 0); + return $this->_results; + } + + $results = $this->decorateResults($res); + /** @psalm-suppress InternalMethod Could not find a better way apart from implementing it as a custom class **/ + $this->_cache?->store($this, $results); + } + $this->_results = $results; + + return $this->_results; + } + + /** + * @param \Closure|array|string $fields The field configuration for the order by clause + * @param bool $overwrite Whether to overwrite the existing conditions + * @return self + * @psalm-suppress LessSpecificImplementedReturnType + */ + public function orderBy(Closure|array|string $fields, bool $overwrite = false): Query + { + $this->order($fields, $overwrite); + + return $this; + } + + /** + * Returns the existing type map. + * + * @return \Cake\Database\TypeMap + */ + public function getTypeMap(): TypeMap + { + return $this->_typeMap ??= new TypeMap(); + } + + /** + * Returns an array representation of the results after executing the query. + * + * @return array + */ + public function toArray(): array + { + return $this->all()->toArray(); + } + + /** + * Set the default repository object that will be used by this query. + * + * @param \Cake\Datasource\RepositoryInterface $repository The default repository object to use. + * @return self + * @psalm-suppress LessSpecificImplementedReturnType + */ + public function setRepository(RepositoryInterface $repository): Query + { + assert( + $repository instanceof Endpoint, + '`$repository` must be an instance of `' . Endpoint::class . '`.' + ); + $this->_endpoint = $repository; + + return $this; + } + + /** + * Returns the default repository object that will be used by this query, + * that is, the table that will appear in the from clause. + * + * @return \Muffin\Webservice\Model\Endpoint + */ + public function getRepository(): Endpoint + { + return $this->_endpoint; + } + /** * Mark the query as create * - * @return $this + * @return self */ - public function create() + public function create(): Query { $this->action(self::ACTION_CREATE); @@ -114,9 +306,9 @@ public function create() /** * Mark the query as read * - * @return $this + * @return self */ - public function read() + public function read(): Query { $this->action(self::ACTION_READ); @@ -126,9 +318,9 @@ public function read() /** * Mark the query as update * - * @return $this + * @return self */ - public function update() + public function update(): Query { $this->action(self::ACTION_UPDATE); @@ -138,9 +330,9 @@ public function update() /** * Mark the query as delete * - * @return $this + * @return self */ - public function delete() + public function delete(): Query { $this->action(self::ACTION_DELETE); @@ -158,7 +350,7 @@ public function delete() * @param string $name name of the clause to be returned * @return mixed */ - public function clause(string $name) + public function clause(string $name): mixed { if (isset($this->_parts[$name])) { return $this->_parts[$name]; @@ -171,12 +363,11 @@ public function clause(string $name) * Set the endpoint to be used * * @param \Muffin\Webservice\Model\Endpoint $endpoint The endpoint to use - * @return $this - * @psalm-suppress LessSpecificReturnStatement + * @return self */ - public function setEndpoint(Endpoint $endpoint) + public function setEndpoint(Endpoint $endpoint): Query { - $this->setRepository($endpoint); + $this->_endpoint = $endpoint; return $this; } @@ -185,21 +376,19 @@ public function setEndpoint(Endpoint $endpoint) * Set the endpoint to be used * * @return \Muffin\Webservice\Model\Endpoint - * @psalm-suppress MoreSpecificReturnType */ public function getEndpoint(): Endpoint { - /** @var \Muffin\Webservice\Model\Endpoint */ - return $this->getRepository(); + return $this->_endpoint; } /** * Set the webservice to be used * * @param \Muffin\Webservice\Webservice\WebserviceInterface $webservice The webservice to use - * @return $this + * @return self */ - public function setWebservice(WebserviceInterface $webservice) + public function setWebservice(WebserviceInterface $webservice): Query { $this->_webservice = $webservice; @@ -211,7 +400,7 @@ public function setWebservice(WebserviceInterface $webservice) * * @return \Muffin\Webservice\Webservice\WebserviceInterface */ - public function getWebservice() + public function getWebservice(): WebserviceInterface { return $this->_webservice; } @@ -229,22 +418,22 @@ public function getWebservice() * a single query. * * @param string $finder The finder method to use. - * @param array $options The options for the finder. - * @return $this Returns a modified query. - */ - public function find($finder, array $options = []) + * @param mixed ...$args Arguments that match up to finder-specific parameters + * @return static Returns a modified query. + * @psalm-suppress MoreSpecificReturnType Couldn't get it to work with the interface and has no impact **/ + public function find(string $finder, mixed ...$args): static { - /** @psalm-suppress UndefinedInterfaceMethod */ - return $this->getRepository()->callFinder($finder, $this, $options); + /** @psalm-suppress LessSpecificReturnStatement Couldn't get it to work with the interface and has no impact **/ + return $this->_endpoint->callFinder($finder, $this, $args); /* @phpstan-ignore-line */ } /** * Get the first result from the executing query or raise an exception. * * @return mixed The first result from the ResultSet. - * @throws \Cake\Datasource\Exception\RecordNotFoundException When there is no first record. + * @throws \Cake\Datasource\Exception\RecordNotFoundException|\Exception When there is no first record. */ - public function firstOrFail() + public function firstOrFail(): mixed { $entity = $this->first(); if ($entity) { @@ -253,7 +442,7 @@ public function firstOrFail() /** @psalm-suppress UndefinedInterfaceMethod */ throw new RecordNotFoundException(sprintf( 'Record not found in endpoint "%s"', - $this->getRepository()->getName() + $this->_endpoint->getName() )); } @@ -272,18 +461,17 @@ public function aliasField(string $field, ?string $alias = null): array /** * Apply conditions to the query * - * @param array|null $conditions The conditions to apply - * @param array $types Not used - * @param bool $overwrite Whether to overwrite the current conditions - * @return $this - * @psalm-suppress MoreSpecificImplementedParamType - */ - public function where($conditions = null, array $types = [], bool $overwrite = false) - { - if ($conditions === null) { - return $this->clause('where'); - } - + * @param \Closure|array|string|null $conditions The list of conditions. + * @param array $types Not used, required to comply with QueryInterface. + * @param bool $overwrite Whether to replace previous queries. + * @return self + * @psalm-suppress LessSpecificImplementedReturnType + */ + public function where( + Closure|array|string|null $conditions = null, + array $types = [], + bool $overwrite = false + ): Query { $this->_parts['where'] = !$overwrite ? Hash::merge($this->clause('where'), $conditions) : $conditions; return $this; @@ -292,14 +480,14 @@ public function where($conditions = null, array $types = [], bool $overwrite = f /** * Add AND conditions to the query * - * @param string|array $conditions The conditions to add with AND. + * @param array|string $conditions The conditions to add with AND. * @param array $types associative array of type names used to bind values to query - * @return $this + * @return self * @see \Cake\Database\Query::where() * @see \Cake\Database\Type * @psalm-suppress PossiblyInvalidArgument */ - public function andWhere($conditions, array $types = []) + public function andWhere(string|array $conditions, array $types = []): Query { $this->where($conditions, $types); @@ -310,9 +498,9 @@ public function andWhere($conditions, array $types = []) * Charge this query's action * * @param int $action Action to use - * @return $this + * @return self */ - public function action(int $action) + public function action(int $action): Query { $this->_parts['action'] = $action; @@ -329,11 +517,12 @@ public function action(int $action) * Pages should start at 1. * * @param int $num The page number you want. - * @param int $limit The number of rows you want in the page. If null + * @param int|null $limit The number of rows you want in the page. If null * the current limit clause will be used. - * @return $this + * @return self + * @psalm-suppress LessSpecificImplementedReturnType */ - public function page(int $num, ?int $limit = null) + public function page(int $num, ?int $limit = null): Query { if ($num < 1) { throw new InvalidArgumentException('Pages must start at 1.'); @@ -360,12 +549,11 @@ public function page(int $num, ?int $limit = null) * $query->limit(10) // generates LIMIT 10 * ``` * - * @param int $limit number of records to be returned - * @return $this - * @psalm-suppress MoreSpecificImplementedParamType - * @psalm-suppress ParamNameMismatch + * @param ?int $limit number of records to be returned + * @return self + * @psalm-suppress LessSpecificImplementedReturnType */ - public function limit($limit) + public function limit(?int $limit): Query { $this->_parts['limit'] = $limit; @@ -375,17 +563,13 @@ public function limit($limit) /** * Set fields to save in resources * - * @param array|null $fields The field to set - * @return $this|array + * @param \Closure|array|string $fields The field to set + * @return self */ - public function set($fields = null) + public function set(Closure|array|string $fields): Query { - if ($fields === null) { - return $this->clause('set'); - } - if (!in_array($this->clause('action'), [self::ACTION_CREATE, self::ACTION_UPDATE])) { - throw new \UnexpectedValueException(__('The action of this query needs to be either create update')); + throw new UnexpectedValueException('The action of this query needs to be either create update'); } $this->_parts['set'] = $fields; @@ -396,7 +580,7 @@ public function set($fields = null) /** * @inheritDoc */ - public function offset($offset) + public function offset(?int $offset): QueryInterface { $this->_parts['offset'] = $offset; @@ -416,11 +600,12 @@ public function offset($offset) * By default this function will append any passed argument to the list of fields * to be selected, unless the second argument is set to true. * - * @param array|\Cake\Database\ExpressionInterface|\Closure|string $fields fields to be added to the list + * @param \Cake\Database\ExpressionInterface|\Closure|array|string $fields fields to be added to the list * @param bool $overwrite whether to reset order with field list or not - * @return $this + * @return self + * @psalm-suppress LessSpecificImplementedReturnType */ - public function order($fields, $overwrite = false) + public function order(array|ExpressionInterface|Closure|string $fields, bool $overwrite = false): Query { $this->_parts['order'] = !$overwrite ? Hash::merge($this->clause('order'), $fields) : $fields; @@ -434,7 +619,7 @@ public function order($fields, $overwrite = false) * @param array $options the options to be applied * @return $this This object */ - public function applyOptions(array $options) + public function applyOptions(array $options): Query { if (isset($options['page'])) { $this->page($options['page']); @@ -473,16 +658,22 @@ public function count(): int return 0; } - if (!$this->_result) { + if ($this->_results === null || $this->_results === false) { $this->_execute(); } - if ($this->_result) { - /** @psalm-suppress PossiblyInvalidMethodCall, PossiblyUndefinedMethod */ - return (int)$this->_result->total(); + if ($this->_results instanceof ResultSet) { + return (int)$this->_results->total(); + } + if ($this->_results instanceof ResultSetInterface) { + return $this->_results->count(); + } + if ($this->_results === null) { + return 0; } - return 0; + // There is a single integer or boolean value + return 1; } /** @@ -495,11 +686,11 @@ public function count(): int * $singleUser = $query->first(); * ``` * - * @return \Cake\Datasource\EntityInterface|array|null the first result from the ResultSet + * @return mixed the first result from the ResultSet */ - public function first() + public function first(): mixed { - if (!$this->_result) { + if ($this->_dirty) { $this->limit(1); } @@ -513,7 +704,7 @@ public function first() * * @return void */ - public function triggerBeforeFind() + public function triggerBeforeFind(): void { if (!$this->_beforeFindFired && $this->clause('action') === self::ACTION_READ) { /** @var \Muffin\Webservice\Model\Endpoint $endpoint */ @@ -530,36 +721,32 @@ public function triggerBeforeFind() /** * Execute the query * - * @return bool|int|\Muffin\Webservice\Model\Resource|\Muffin\Webservice\Datasource\ResultSet - * @psalm-suppress MoreSpecificReturnType + * @return Resource|\Cake\Datasource\ResultSetInterface|int|bool */ - public function execute() + public function execute(): bool|int|Resource|ResultSetInterface { if ($this->clause('action') === self::ACTION_READ) { - /** @psalm-suppress LessSpecificReturnStatement */ return $this->_execute(); } - return $this->_result = $this->_webservice->execute($this); + return $this->_webservice->execute($this); } /** * Executes this query and returns a traversable object containing the results * - * @return \Cake\Datasource\ResultSetInterface + * @return \Muffin\Webservice\Model\Resource|\Cake\Datasource\ResultSetInterface|int|bool */ - protected function _execute(): ResultSetInterface + protected function _execute(): bool|int|Resource|ResultSetInterface { $this->triggerBeforeFind(); - if ($this->_result) { - /** @psalm-var class-string<\Cake\Datasource\ResultSetInterface> $decorator */ - $decorator = $this->_decoratorClass(); + if (is_iterable($this->_results)) { + $decorator = $this->decoratorClass(); - return new $decorator($this->_result); + return new $decorator($this->_results); } - /** @var \Cake\Datasource\ResultSetInterface */ - return $this->_result = $this->_webservice->execute($this); + return $this->_results = $this->_webservice->execute($this); } /** @@ -567,7 +754,7 @@ protected function _execute(): ResultSetInterface * * @return array */ - public function __debugInfo() + public function __debugInfo(): array { return [ '(help)' => 'This is a Query object, to get the results execute or iterate it.', @@ -576,10 +763,10 @@ public function __debugInfo() 'offset' => $this->clause('offset'), 'page' => $this->clause('page'), 'limit' => $this->clause('limit'), - 'set' => $this->set(), + 'set' => $this->clause('set'), 'sort' => $this->clause('order'), 'extraOptions' => $this->getOptions(), - 'conditions' => $this->where(), + 'conditions' => $this->clause('where'), 'repository' => $this->getEndpoint(), 'webservice' => $this->getWebservice(), ]; @@ -592,21 +779,26 @@ public function __debugInfo() * * @return \Cake\Datasource\ResultSetInterface The data to convert to JSON. */ - public function jsonSerialize() + public function jsonSerialize(): ResultSetInterface { return $this->all(); } /** - * Select the fields to include in the query + * Adds fields to be selected from _source. * - * @param array|\Cake\Database\ExpressionInterface|string|callable $fields fields to be added to the list. - * @param bool $overwrite whether to reset fields with passed list or not - * @return $this - * @see \Cake\Database\Query::select - * @psalm-suppress MoreSpecificImplementedParamType + * Calling this function multiple times will append more fields to the + * list of fields to be selected from _source. + * + * If `true` is passed in the second argument, any previous selections + * will be overwritten with the list passed in the first argument. + * + * @param \Cake\Database\ExpressionInterface|\Closure|array|string|float|int $fields The list of fields to select from _source. + * @param bool $overwrite Whether or not to replace previous selections. + * @return self + * @psalm-suppress LessSpecificImplementedReturnType */ - public function select($fields = [], bool $overwrite = false) + public function select(ExpressionInterface|Closure|array|string|int|float $fields, bool $overwrite = false): Query { if (!is_string($fields) && is_callable($fields)) { $fields = $fields($this); @@ -624,4 +816,193 @@ public function select($fields = [], bool $overwrite = false) return $this; } + + /** + * Returns the name of the class to be used for decorating results + * + * @return class-string<\Cake\Datasource\ResultSetInterface> + */ + protected function decoratorClass(): string + { + return ResultSetDecorator::class; + } + + /** + * Decorates the results iterator with MapReduce routines and formatters + * + * @param iterable $result Original results + * @return \Cake\Datasource\ResultSetInterface + */ + protected function decorateResults(iterable $result): ResultSetInterface + { + $decorator = $this->decoratorClass(); + + if (!empty($this->_mapReduce)) { + foreach ($this->_mapReduce as $functions) { + $result = new MapReduce($result, $functions['mapper'], $functions['reducer']); + } + $result = new $decorator($result); + } + + if (!($result instanceof ResultSetInterface)) { + $result = new $decorator($result); + } + + if (!empty($this->_formatters)) { + foreach ($this->_formatters as $formatter) { + $result = $formatter($result, $this); + } + + if (!($result instanceof ResultSetInterface)) { + $result = new $decorator($result); + } + } + + return $result; + } + + /** + * Register a new MapReduce routine to be executed on top of the database results + * + * The MapReduce routing will only be run when the query is executed and the first + * result is attempted to be fetched. + * + * If the third argument is set to true, it will erase previous map reducers + * and replace it with the arguments passed. + * + * @param \Closure|null $mapper The mapper function + * @param \Closure|null $reducer The reducing function + * @param bool $overwrite Set to true to overwrite existing map + reduce functions. + * @return self + * @see \Cake\Collection\Iterator\MapReduce for details on how to use emit data to the map reducer. + */ + public function mapReduce(?Closure $mapper = null, ?Closure $reducer = null, bool $overwrite = false): Query + { + if ($overwrite) { + $this->_mapReduce = []; + } + if ($mapper === null) { + if (!$overwrite) { + throw new InvalidArgumentException('$mapper can be null only when $overwrite is true.'); + } + + return $this; + } + $this->_mapReduce[] = compact('mapper', 'reducer'); + + return $this; + } + + /** + * Returns an array with the custom options that were applied to this query + * and that were not already processed by another method in this class. + * + * ### Example: + * + * ``` + * $query->applyOptions(['doABarrelRoll' => true, 'fields' => ['id', 'name']); + * $query->getOptions(); // Returns ['doABarrelRoll' => true] + * ``` + * + * @see \Cake\Datasource\QueryInterface::applyOptions() to read about the options that will + * be processed by this class and not returned by this function + * @return array + * @see applyOptions() + */ + public function getOptions(): array + { + return $this->_options; + } + + /** + * Returns the current configured query `_eagerLoaded` value + * + * @return bool + */ + public function isEagerLoaded(): bool + { + return $this->_eagerLoaded; + } + + /** + * Sets the query instance to be an eager loaded query. If no argument is + * passed, the current configured query `_eagerLoaded` value is returned. + * + * @param bool $value Whether to eager load. + * @return self + */ + public function eagerLoaded(bool $value): Query + { + $this->_eagerLoaded = $value; + + return $this; + } + + /** + * Registers a new formatter callback function that is to be executed when trying + * to fetch the results from the database. + * + * If the second argument is set to true, it will erase previous formatters + * and replace them with the passed first argument. + * + * Callbacks are required to return an iterator object, which will be used as + * the return value for this query's result. Formatter functions are applied + * after all the `MapReduce` routines for this query have been executed. + * + * Formatting callbacks will receive two arguments, the first one being an object + * implementing `\Cake\Collection\CollectionInterface`, that can be traversed and + * modified at will. The second one being the query instance on which the formatter + * callback is being applied. + * + * ### Examples: + * + * Return all results from the table indexed by id: + * + * ``` + * $query->select(['id', 'name'])->formatResults(function ($results) { + * return $results->indexBy('id'); + * }); + * ``` + * + * Add a new column to the ResultSet: + * + * ``` + * $query->select(['name', 'birth_date'])->formatResults(function ($results) { + * return $results->map(function ($row) { + * $row['age'] = $row['birth_date']->diff(new DateTime)->y; + * + * return $row; + * }); + * }); + * ``` + * + * @param \Closure|null $formatter The formatting function + * @param int|bool $mode Whether to overwrite, append or prepend the formatter. + * @return self + * @throws \InvalidArgumentException + */ + public function formatResults(?Closure $formatter = null, int|bool $mode = self::APPEND): Query + { + if ($mode === self::OVERWRITE) { + $this->_formatters = []; + } + if ($formatter === null) { + /** @psalm-suppress RedundantCondition */ + if ($mode !== self::OVERWRITE) { + throw new InvalidArgumentException('$formatter can be null only when $mode is overwrite.'); + } + + return $this; + } + + if ($mode === self::PREPEND) { + array_unshift($this->_formatters, $formatter); + + return $this; + } + + $this->_formatters[] = $formatter; + + return $this; + } } diff --git a/src/Datasource/ResultSet.php b/src/Datasource/ResultSet.php index 5978fe7..9e224e4 100644 --- a/src/Datasource/ResultSet.php +++ b/src/Datasource/ResultSet.php @@ -5,7 +5,9 @@ use Cake\Collection\CollectionTrait; use Cake\Datasource\ResultSetInterface; +use Muffin\Webservice\Model\Resource; +/** @package Muffin\Webservice\Datasource */ /** * @template T of \Cake\Datasource\EntityInterface|array * @implements \Cake\Datasource\ResultSetInterface @@ -19,29 +21,28 @@ class ResultSet implements ResultSetInterface * * @var int */ - protected $_index = 0; + protected int $_index = 0; /** * Last record fetched from the statement * - * @var \Cake\Datasource\EntityInterface|array - * @psalm-var T + * @var Resource */ - protected $_current; + protected Resource $_current; /** * Results that have been fetched or hydrated into the results. * * @var array */ - protected $_results = []; + protected array $_results = []; /** * Total number of results * * @var int|null */ - protected $_total; + protected ?int $_total = null; /** * Construct the ResultSet @@ -60,11 +61,9 @@ public function __construct(array $resources, ?int $total = null) * * Part of Iterator interface. * - * @return \Cake\Datasource\EntityInterface|array - * @psalm-return T + * @return Resource */ - #[\ReturnTypeWillChange] - public function current() + public function current(): Resource { return $this->_current; } @@ -76,7 +75,7 @@ public function current() * * @return void */ - public function rewind() + public function rewind(): void { $this->_index = 0; } @@ -88,7 +87,7 @@ public function rewind() * * @return string Serialized object */ - public function serialize() + public function serialize(): string { while ($this->valid()) { $this->next(); @@ -104,7 +103,7 @@ public function serialize() * * @return bool */ - public function valid() + public function valid(): bool { if (!isset($this->_results[$this->key()])) { return false; @@ -122,7 +121,7 @@ public function valid() * * @return int */ - public function key() + public function key(): int { return $this->_index; } @@ -134,7 +133,7 @@ public function key() * * @return void */ - public function next() + public function next(): void { $this->_index++; } @@ -147,7 +146,7 @@ public function next() * @param string $serialized Serialized object * @return void */ - public function unserialize($serialized) + public function unserialize(string $serialized): void { $this->_results = unserialize($serialized); } diff --git a/src/Datasource/Schema.php b/src/Datasource/Schema.php index 310d1b3..b303e56 100644 --- a/src/Datasource/Schema.php +++ b/src/Datasource/Schema.php @@ -20,49 +20,49 @@ class Schema implements SchemaInterface * * @var string */ - protected $_repository; + protected string $_repository; /** * Columns in the endpoint. * * @var array */ - protected $_columns = []; + protected array $_columns = []; /** * A map with columns to types * - * @var array + * @var array */ - protected $_typeMap = []; + protected array $_typeMap = []; /** * Indexes in the endpoint. * * @var array */ - protected $_indexes = []; + protected array $_indexes = []; /** * Constraints in the endpoint. * * @var array */ - protected $_constraints = []; + protected array $_constraints = []; /** * Options for the endpoint. * - * @var array + * @var array */ - protected $_options = []; + protected array $_options = []; /** * Whether or not the endpoint is temporary * * @var bool */ - protected $_temporary = false; + protected bool $_temporary = false; /** * The valid keys that can be used in a column @@ -70,7 +70,7 @@ class Schema implements SchemaInterface * * @var array */ - protected static $_columnKeys = [ + protected static array $_columnKeys = [ 'type' => null, 'baseType' => null, 'length' => null, @@ -86,7 +86,7 @@ class Schema implements SchemaInterface * * @var array */ - protected static $_columnExtras = [ + protected static array $_columnExtras = [ 'string' => [ 'fixed' => null, ], @@ -157,9 +157,10 @@ public function name(): string * * @param string $name The name of the column * @param array|string $attrs The attributes for the column. - * @return $this + * @return self + * @psalm-suppress LessSpecificImplementedReturnType */ - public function addColumn(string $name, $attrs) + public function addColumn(string $name, array|string $attrs): Schema { if (is_string($attrs)) { $attrs = ['type' => $attrs]; @@ -178,7 +179,7 @@ public function addColumn(string $name, $attrs) /** * Get the column names in the endpoint. * - * @return string[] + * @return list */ public function columns(): array { @@ -219,9 +220,10 @@ public function hasColumn(string $name): bool * If the column is not defined in the table, no error will be raised. * * @param string $name The name of the column - * @return $this + * @return self + * @psalm-suppress LessSpecificImplementedReturnType */ - public function removeColumn(string $name) + public function removeColumn(string $name): Schema { unset($this->_columns[$name], $this->_typeMap[$name]); @@ -233,9 +235,10 @@ public function removeColumn(string $name) * * @param string $name Column name * @param string $type Type to set for the column - * @return $this + * @return self + * @psalm-suppress LessSpecificImplementedReturnType */ - public function setColumnType(string $name, string $type) + public function setColumnType(string $name, string $type): Schema { $this->_columns[$name]['type'] = $type; $this->_typeMap[$name] = $type; @@ -247,7 +250,7 @@ public function setColumnType(string $name, string $type) * Get the type of a column * * @param string $name Column name - * @return null|string + * @return string|null */ public function getColumnType(string $name): ?string { @@ -278,7 +281,7 @@ public function baseColumnType(string $column): ?string return null; } - if (TypeFactory::getMap($type)) { + if (TypeFactory::getMap($type) !== null) { $type = TypeFactory::build($type)->getBaseType(); } @@ -337,7 +340,7 @@ public function defaultValues(): array /** * Get the column(s) used for the primary key. * - * @return array Column name(s) for the primary key. An + * @return list Column name(s) for the primary key. An * empty list will be returned when the endpoint has no primary key. */ public function getPrimaryKey(): array @@ -358,9 +361,10 @@ public function getPrimaryKey(): array * Set the schema options for an endpoint * * @param array $options Array of options to set - * @return $this + * @return self + * @psalm-suppress LessSpecificImplementedReturnType */ - public function setOptions(array $options) + public function setOptions(array $options): Schema { $this->_options = array_merge($this->_options, $options); diff --git a/src/Model/Endpoint.php b/src/Model/Endpoint.php index 34c36f6..ca7fc2c 100644 --- a/src/Model/Endpoint.php +++ b/src/Model/Endpoint.php @@ -3,12 +3,17 @@ namespace Muffin\Webservice\Model; +use ArrayAccess; use ArrayObject; use BadMethodCallException; +use Cake\Collection\CollectionInterface; use Cake\Core\App; use Cake\Datasource\EntityInterface; use Cake\Datasource\Exception\InvalidPrimaryKeyException; +use Cake\Datasource\Exception\RecordNotFoundException; +use Cake\Datasource\QueryInterface; use Cake\Datasource\RepositoryInterface; +use Cake\Datasource\ResultSetInterface; use Cake\Datasource\RulesAwareTrait; use Cake\Datasource\RulesChecker; use Cake\Event\EventDispatcherInterface; @@ -17,20 +22,30 @@ use Cake\ORM\Exception\PersistenceFailedException; use Cake\Utility\Inflector; use Cake\Validation\ValidatorAwareTrait; +use Closure; +use Exception; use Muffin\Webservice\Datasource\Connection; use Muffin\Webservice\Datasource\Marshaller; use Muffin\Webservice\Datasource\Query; use Muffin\Webservice\Datasource\Schema; use Muffin\Webservice\Model\Exception\MissingResourceClassException; use Muffin\Webservice\Webservice\WebserviceInterface; +use Psr\SimpleCache\CacheInterface; +use function Cake\Core\namespaceSplit; /** * The table equivalent of a webservice endpoint * * @package Muffin\Webservice\Model + * @template TSubject of object + * @implements \Cake\Event\EventDispatcherInterface + * @psalm-consistent-constructor */ class Endpoint implements RepositoryInterface, EventListenerInterface, EventDispatcherInterface { + /** + * @use \Cake\Event\EventDispatcherTrait<\Muffin\Webservice\Model\Endpoint> + */ use EventDispatcherTrait; use RulesAwareTrait; use ValidatorAwareTrait; @@ -50,75 +65,75 @@ class Endpoint implements RepositoryInterface, EventListenerInterface, EventDisp public const VALIDATOR_PROVIDER_NAME = 'endpoint'; /** - * Connection instance this endpoint uses + * The webservice instance to call * - * @var \Muffin\Webservice\Datasource\Connection + * @var \Muffin\Webservice\Webservice\WebserviceInterface|null */ - protected $_connection; + protected ?WebserviceInterface $_webservice = null; /** - * The schema object containing a description of this endpoint fields + * The alias to use for the endpoint * - * @var \Muffin\Webservice\Datasource\Schema + * @var string|null */ - protected $_schema; + protected ?string $_alias = null; /** - * The name of the class that represent a single resource for this endpoint + * Connection instance this endpoint uses * - * @var string - * @psalm-var class-string<\Muffin\Webservice\Model\Resource> + * @var \Muffin\Webservice\Datasource\Connection|null */ - protected $_resourceClass; + protected ?Connection $_connection = null; /** - * Registry key used to create this endpoint object + * The schema object containing a description of this endpoint fields * - * @var string + * @var \Muffin\Webservice\Datasource\Schema|null */ - protected $_registryAlias; + protected ?Schema $_schema = null; /** - * The name of the endpoint to contact + * The name of the field that represents the primary key in the endpoint * - * @var string + * @var list|string|null */ - protected $_name; + protected array|string|null $_primaryKey = null; /** - * The name of the field that represents the primary key in the endpoint + * The name of the field that represents a human readable representation of a row * - * @var string|array|null + * @var array|string|null */ - protected $_primaryKey; + protected array|string|null $_displayField = null; /** - * The name of the field that represents a human readable representation of a row + * The name of the endpoint to contact * - * @var string|string[] + * @var string|null */ - protected $_displayField; + protected ?string $_name = null; /** - * The webservice instance to call + * The name of the class that represent a single resource for this endpoint * - * @var \Muffin\Webservice\Webservice\WebserviceInterface + * @var string|null + * @psalm-var class-string<\Muffin\Webservice\Model\Resource> */ - protected $_webservice; + protected ?string $_resourceClass = null; /** - * The alias to use for the endpoint + * Registry key used to create this endpoint object * - * @var string + * @var string|null */ - protected $_alias; + protected ?string $_registryAlias = null; /** * The inflect method to use for endpoint routes * * @var string */ - protected $_inflectionMethod = 'underscore'; + protected string $_inflectionMethod = 'underscore'; /** * Initializes a new instance @@ -135,6 +150,7 @@ class Endpoint implements RepositoryInterface, EventListenerInterface, EventDisp * passed to it. * * @param array $config List of options for this endpoint + * @throws \Exception */ public function __construct(array $config = []) { @@ -185,6 +201,7 @@ public function __construct(array $config = []) * instance is created through the EndpointRegistry without a connection. * * @return string + * @throws \Exception When the plugin name cannot be found * @see \Muffin\Webservice\Model\EndpointRegistry::get() */ public static function defaultConnectionName(): string @@ -192,6 +209,10 @@ public static function defaultConnectionName(): string $namespaceParts = explode('\\', static::class); $plugin = current(array_slice(array_reverse($namespaceParts), 3, 2)); + if ($plugin === false) { + throw new Exception('Could not find plugin name'); + } + if ($plugin === 'App') { return 'webservice'; } @@ -222,9 +243,9 @@ public function initialize(array $config): void * Set the name of this endpoint * * @param string $name The name for this endpoint instance - * @return $this + * @return self */ - public function setName(string $name) + public function setName(string $name): Endpoint { $inflectMethod = $this->getInflectionMethod(); $this->_name = Inflector::{$inflectMethod}($name); @@ -236,12 +257,19 @@ public function setName(string $name) * Get the name of this endpoint * * @return string + * @throws \Exception When the endpoint could not be determined from the name */ public function getName(): string { if ($this->_name === null) { $endpoint = namespaceSplit(static::class); - $endpoint = substr(end($endpoint), 0, -8); + $name = end($endpoint); + + if ($name === false) { + throw new Exception('Could not find the name of the endpoint'); + } + + $endpoint = substr($name, 0, -8); $inflectMethod = $this->getInflectionMethod(); $this->_name = Inflector::{$inflectMethod}($endpoint); @@ -265,9 +293,10 @@ public function aliasField(string $field): string * Sets the table registry key used to create this table instance. * * @param string $registryAlias The key used to access this object. - * @return $this + * @return self + * @psalm-suppress LessSpecificImplementedReturnType */ - public function setRegistryAlias(string $registryAlias) + public function setRegistryAlias(string $registryAlias): Endpoint { $this->_registryAlias = $registryAlias; @@ -292,9 +321,9 @@ public function getRegistryAlias(): string * Sets the connection driver. * * @param \Muffin\Webservice\Datasource\Connection $connection Connection instance - * @return $this + * @return self */ - public function setConnection(Connection $connection) + public function setConnection(Connection $connection): Endpoint { $this->_connection = $connection; @@ -308,6 +337,11 @@ public function setConnection(Connection $connection) */ public function getConnection(): Connection { + assert( + $this->_connection !== null, + 'Connection is null, there is no connection to return.' + ); + return $this->_connection; } @@ -321,9 +355,10 @@ public function getConnection(): Connection * out of it and used as the schema for this endpoint. * * @param \Muffin\Webservice\Datasource\Schema|array $schema Either an array of fields and config, or a schema object - * @return $this + * @return self + * @throws \Exception */ - public function setSchema($schema) + public function setSchema(Schema|array $schema): Endpoint { if (is_array($schema)) { $schema = new Schema($this->getName(), $schema); @@ -337,44 +372,18 @@ public function setSchema($schema) /** * Returns the schema endpoint object describing this endpoint's properties. * - * @return \Muffin\Webservice\Datasource\Schema + * @return \Muffin\Webservice\Datasource\Schema|null + * @throws \Exception */ - public function getSchema(): Schema + public function getSchema(): ?Schema { if ($this->_schema === null) { - $this->_schema = $this->_initializeSchema($this->getWebservice()->describe($this->getName())); + $this->_schema = $this->getWebservice()->describe($this->getName()); } return $this->_schema; } - /** - * Override this function in order to alter the schema used by this endpoint. - * This function is only called after fetching the schema out of the webservice. - * If you wish to provide your own schema to this table without touching the - * database, you can override schema() or inject the definitions though that - * method. - * - * ### Example: - * - * ``` - * protected function _initializeSchema(\Muffin\Webservice\Schema $schema) { - * $schema->addColumn('preferences', [ - * 'type' => 'string' - * ]); - * return $schema; - * } - * ``` - * - * @param \Muffin\Webservice\Datasource\Schema $schema The schema definition fetched from webservice. - * @return \Muffin\Webservice\Datasource\Schema the altered schema - * @api - */ - protected function _initializeSchema(Schema $schema): Schema - { - return $schema; - } - /** * Test to see if a Table has a specific field/column. * @@ -383,21 +392,22 @@ protected function _initializeSchema(Schema $schema): Schema * * @param string $field The field to check for. * @return bool True if the field exists, false if it does not. + * @throws \Exception */ public function hasField(string $field): bool { $schema = $this->getSchema(); - return $schema->getColumn($field) !== null; + return $schema?->getColumn($field) !== null; } /** - * Returns the primary key field name + * Returns the current endpoint * - * @param string|array|null $key sets a new name to be used as primary key - * @return $this + * @param list|string|null $key sets a new name to be used as primary key + * @return self */ - public function setPrimaryKey($key) + public function setPrimaryKey(string|array|null $key): Endpoint { $this->_primaryKey = $key; @@ -407,15 +417,15 @@ public function setPrimaryKey($key) /** * Get the endpoints primary key, if one is not set, fetch it from the schema * - * @return array|string + * @return list|string|null * @throws \Muffin\Webservice\Webservice\Exception\UnexpectedDriverException When no schema exists to fetch the key from */ - public function getPrimaryKey() + public function getPrimaryKey(): array|string|null { if ($this->_primaryKey === null) { $schema = $this->getSchema(); - $key = $schema->getPrimaryKey(); - if (count($key) === 1) { + $key = $schema?->getPrimaryKey(); + if ($key !== null && count($key) === 1) { $key = $key[0]; } $this->_primaryKey = $key; @@ -427,10 +437,10 @@ public function getPrimaryKey() /** * Sets the endpoint display field * - * @param string|string[] $field The new field to use as the display field - * @return $this + * @param array|string $field The new field to use as the display field + * @return self */ - public function setDisplayField($field) + public function setDisplayField(string|array $field): Endpoint { $this->_displayField = $field; @@ -440,20 +450,20 @@ public function setDisplayField($field) /** * Get the endpoints current display field * - * @return string|string[] + * @return array|string|null * @throws \Muffin\Webservice\Webservice\Exception\UnexpectedDriverException When no schema exists to fetch the key from */ - public function getDisplayField() + public function getDisplayField(): string|array|null { if ($this->_displayField === null) { $primary = (array)$this->getPrimaryKey(); $this->_displayField = array_shift($primary); $schema = $this->getSchema(); - if ($schema->getColumn('title')) { + if ($schema?->getColumn('title') !== null) { $this->_displayField = 'title'; } - if ($schema->getColumn('name')) { + if ($schema?->getColumn('name') !== null) { $this->_displayField = 'name'; } } @@ -465,12 +475,12 @@ public function getDisplayField() * Set the resource class name used to hydrate resources for this endpoint * * @param string $name Name of the class to use - * @return $this + * @return self * @throws \Muffin\Webservice\Model\Exception\MissingResourceClassException If the resource class specified does not exist */ - public function setResourceClass(string $name) + public function setResourceClass(string $name): Endpoint { - /** @psalm-var class-string<\Muffin\Webservice\Model\Resource>|null */ + /** @psalm-var class-string<\Muffin\Webservice\Model\Resource>|null $className */ $className = App::className($name, 'Model/Resource'); if (!$className) { throw new MissingResourceClassException([$name]); @@ -499,8 +509,9 @@ public function getResourceClass(): string } $alias = Inflector::singularize(substr(array_pop($parts), 0, -8)); - /** @psalm-var class-string<\Muffin\Webservice\Model\Resource> */ + /** @psalm-var class-string<\Muffin\Webservice\Model\Resource> $alias */ $name = implode('\\', array_slice($parts, 0, -1)) . '\Resource\\' . $alias; + /** @psalm-var class-string<\Muffin\Webservice\Model\Resource> $name */ if (!class_exists($name)) { return $this->_resourceClass = $default; } @@ -515,9 +526,9 @@ public function getResourceClass(): string * Set a new inflection method * * @param string $method The name of the inflection method - * @return $this + * @return self */ - public function setInflectionMethod(string $method) + public function setInflectionMethod(string $method): Endpoint { $this->_inflectionMethod = $method; @@ -539,10 +550,10 @@ public function getInflectionMethod(): string * * @param string $alias Alias for the webservice * @param \Muffin\Webservice\Webservice\WebserviceInterface $webservice The webservice instance - * @return $this + * @return self * @throws \Muffin\Webservice\Webservice\Exception\UnexpectedDriverException When no driver exists for the endpoint */ - public function setWebservice(string $alias, WebserviceInterface $webservice) + public function setWebservice(string $alias, WebserviceInterface $webservice): Endpoint { $connection = $this->getConnection(); $connection->setWebservice($alias, $webservice); @@ -555,9 +566,11 @@ public function setWebservice(string $alias, WebserviceInterface $webservice) * Get this endpoints associated webservice * * @return \Muffin\Webservice\Webservice\WebserviceInterface + * @throws \Exception */ public function getWebservice(): WebserviceInterface { + // If no webservice is found, get it from the connection if ($this->_webservice === null) { $this->_webservice = $this->getConnection()->getWebservice($this->getName()); } @@ -575,14 +588,15 @@ public function getWebservice(): WebserviceInterface * listeners. Any listener can set a valid result set using $query * * @param string $type the type of query to perform - * @param array $options An array that will be passed to Query::applyOptions() - * @return \Muffin\Webservice\Datasource\Query + * @param mixed ...$args Arguments that match up to finder-specific parameters + * @return \Cake\Datasource\QueryInterface + * @throws \Exception */ - public function find(string $type = 'all', array $options = []): Query + public function find(string $type = 'all', mixed ...$args): QueryInterface { $query = $this->query()->read(); - return $this->callFinder($type, $query, $options); + return $this->callFinder($type, $query, $args); } /** @@ -659,6 +673,9 @@ public function findAll(Query $query, array $options): Query */ public function findList(Query $query, array $options): Query { + if (isset($options[0])) { + $options = $options[0]; + } $options += [ 'keyField' => $this->getPrimaryKey(), 'valueField' => $this->getDisplayField(), @@ -670,7 +687,7 @@ public function findList(Query $query, array $options): Query ['keyField', 'valueField', 'groupField'] ); - return $query->formatResults(function ($results) use ($options) { + return $query->formatResults(function (CollectionInterface $results) use ($options) { return $results->combine( $options['keyField'], $options['valueField'], @@ -732,15 +749,25 @@ protected function _setFieldMatchers(array $options, array $keys): array * ``` * * @param mixed $primaryKey primary key value to find - * @param array $options Options. - * @throws \Cake\Datasource\Exception\RecordNotFoundException if the record with such id could not be found + * @param array|string $finder The finder to use. Passing an options array is deprecated. + * @param \Psr\SimpleCache\CacheInterface|string|null $cache The cache config to use. + * Defaults to `null`, i.e. no caching. + * @param \Closure|string|null $cacheKey The cache key to use. If not provided + * one will be autogenerated if `$cache` is not null. + * @param mixed ...$args Additional arguments for configuring things like caching. + * @psalm-suppress InvalidReturnType For backwards compatibility. This function can also return array * @return \Cake\Datasource\EntityInterface + * @throws \Exception * @see \Cake\Datasource\RepositoryInterface::find() */ - public function get($primaryKey, array $options = []): EntityInterface - { + public function get( + mixed $primaryKey, + array|string $finder = 'all', + CacheInterface|string|null $cache = null, + Closure|string|null $cacheKey = null, + mixed ...$args + ): EntityInterface { $key = (array)$this->getPrimaryKey(); - $alias = $this->getAlias(); foreach ($key as $index => $keyname) { $key[$index] = $keyname; } @@ -759,15 +786,15 @@ public function get($primaryKey, array $options = []): EntityInterface } $conditions = array_combine($key, $primaryKey); - $cacheConfig = $options['cache'] ?? false; - $cacheKey = $options['key'] ?? false; - $finder = $options['finder'] ?? 'all'; - unset($options['key'], $options['cache'], $options['finder']); + $cacheConfig = $args['cache'] ?? false; + $cacheKey = $args['key'] ?? false; + $finder = $args['finder'] ?? 'all'; + unset($args['key'], $args['cache'], $args['finder']); - $query = $this->find($finder, $options)->where($conditions); + $query = $this->find($finder, $args)->where($conditions); - if ($cacheConfig) { - if (!$cacheKey) { + if (($cacheConfig !== false && $cacheConfig !== null) && is_callable($cache)) { + if ($cacheKey !== null) { $cacheKey = sprintf( 'get:%s.%s%s', $this->getConnection()->configName(), @@ -775,10 +802,15 @@ public function get($primaryKey, array $options = []): EntityInterface json_encode($primaryKey) ); } - $query->cache($cacheKey, $cacheConfig); + $cache($cacheKey, $cacheConfig); } - return $query->firstOrFail(); + $result = $query->firstOrFail(); + if ($result instanceof EntityInterface) { + return $result; + } + + throw new RecordNotFoundException(); } /** @@ -800,7 +832,7 @@ public function get($primaryKey, array $options = []): EntityInterface * @return \Cake\Datasource\EntityInterface|array An entity. * @throws \Cake\ORM\Exception\PersistenceFailedException When the entity couldn't be saved */ - public function findOrCreate($search, ?callable $callback = null) + public function findOrCreate(mixed $search, ?callable $callback = null): EntityInterface|array { $query = $this->find()->where($search); $row = $query->first(); @@ -810,7 +842,7 @@ public function findOrCreate($search, ?callable $callback = null) $entity = $this->newEntity(); $entity->set($search, ['guard' => false]); - if ($callback) { + if (is_callable($callback)) { $callback($entity); } @@ -826,10 +858,13 @@ public function findOrCreate($search, ?callable $callback = null) * Creates a new Query instance for this repository * * @return \Muffin\Webservice\Datasource\Query + * @throws \Exception When non webservice is set */ public function query(): Query { - return new Query($this->getWebservice(), $this); + $webservice = $this->getWebservice(); + + return new Query($webservice, $this); } /** @@ -839,15 +874,28 @@ public function query(): Query * This method will *not* trigger beforeSave/afterSave events. If you need those * first load a collection of records and update them. * - * @param array $fields A hash of field => new value. - * @param mixed $conditions Conditions to be used, accepts anything Query::where() can take. + * @param \Closure|array|string $fields = array(); $fields A hash of field => new value. + * @param \Closure|array|string|null $conditions Conditions to be used, accepts anything Query::where() can take. * @return int Count Returns the affected rows. * @psalm-suppress MoreSpecificImplementedParamType + * @throws \Exception */ - public function updateAll($fields, $conditions): int + public function updateAll(Closure|array|string $fields, Closure|array|string|null $conditions): int { - /** @psalm-suppress PossiblyInvalidMethodCall, PossiblyUndefinedMethod */ - return $this->query()->update()->where($conditions)->set($fields)->execute()->count(); + $res = $this->query()->update()->where($conditions)->set($fields)->execute(); + + if ($res instanceof ResultSetInterface) { + return $res->count(); + } + if ($res === false) { + return 0; + } + if (is_integer($res)) { + return $res; + } + + // The other datatypes indicate only a single entity is updated + return 1; } /** @@ -860,13 +908,25 @@ public function updateAll($fields, $conditions): int * * @param mixed $conditions Conditions to be used, accepts anything Query::where() can take. * @return int Count of affected rows. + * @throws \Exception When the delete action could not be executed * @see \Muffin\Webservice\Endpoint::delete() - * @psalm-suppress InvalidReturnStatement - * @psalm-suppress InvalidReturnType */ - public function deleteAll($conditions): int + public function deleteAll(mixed $conditions): int { - return $this->query()->delete()->where($conditions)->execute(); + $res = $this->query()->delete()->where($conditions)->execute(); + + if ($res instanceof ResultSetInterface) { + return $res->count(); + } + if ($res === false) { + return 0; + } + if (is_integer($res)) { + return $res; + } + + // The other datatypes indicate only a single entity is updated + return 1; } /** @@ -876,7 +936,7 @@ public function deleteAll($conditions): int * @param mixed $conditions list of conditions to pass to the query * @return bool */ - public function exists($conditions): bool + public function exists(mixed $conditions): bool { return $this->find()->where($conditions)->count() > 0; } @@ -887,10 +947,10 @@ public function exists($conditions): bool * of any error. * * @param \Cake\Datasource\EntityInterface $entity the resource to be saved - * @param array|\ArrayAccess $options The options to use when saving. + * @param \ArrayAccess|array $options The options to use when saving. * @return \Cake\Datasource\EntityInterface|false */ - public function save(EntityInterface $entity, $options = []) + public function save(EntityInterface $entity, array|ArrayAccess $options = []): EntityInterface|false { $options = new ArrayObject((array)$options + [ 'checkRules' => true, @@ -927,7 +987,7 @@ public function save(EntityInterface $entity, $options = []) return $event->getResult(); } - $data = $entity->extract($this->getSchema()->columns(), true); + $data = $entity->extract($this->getSchema()?->columns() ?? [], true); if ($entity->isNew()) { $query = $this->query()->create(); @@ -937,7 +997,7 @@ public function save(EntityInterface $entity, $options = []) $query->set($data); $result = $query->execute(); - if (!$result) { + if ($result === false) { return false; } @@ -958,10 +1018,10 @@ public function save(EntityInterface $entity, $options = []) * Delete a single resource. * * @param \Cake\Datasource\EntityInterface $entity The resource to remove. - * @param array|\ArrayAccess $options The options for the delete. + * @param \ArrayAccess|array $options The options for the delete. * @return bool */ - public function delete(EntityInterface $entity, $options = []): bool + public function delete(EntityInterface $entity, array|ArrayAccess $options = []): bool { $primaryKeys = (array)$this->getPrimaryKey(); $values = $entity->extract($primaryKeys); @@ -1001,7 +1061,7 @@ public function callFinder(string $type, Query $query, array $options = []): Que return $this->{$finder}($query, $options); } - throw new \BadMethodCallException( + throw new BadMethodCallException( sprintf('Unknown finder method "%s"', $type) ); } @@ -1011,10 +1071,10 @@ public function callFinder(string $type, Query $query, array $options = []): Que * * @param string $method The method name that was fired. * @param array $args List of arguments passed to the function. - * @return mixed + * @return \Cake\Datasource\QueryInterface * @throws \BadMethodCallException when there are missing arguments, or when and & or are combined. */ - protected function _dynamicFinder(string $method, array $args) + protected function _dynamicFinder(string $method, array $args): QueryInterface { $method = Inflector::underscore($method); preg_match('/^find_([\w]+)_by_/', $method, $matches); @@ -1074,10 +1134,10 @@ protected function _dynamicFinder(string $method, array $args) * * @param string $method name of the method to be invoked * @param array $args List of arguments passed to the function - * @return mixed + * @return \Cake\Datasource\QueryInterface * @throws \BadMethodCallException If the request dynamic finder cannot be found */ - public function __call($method, $args) + public function __call(string $method, array $args): QueryInterface { if (preg_match('/^find(?:\w+)?By/', $method) > 0) { return $this->_dynamicFinder($method, $args); @@ -1131,9 +1191,8 @@ public function newEntity(array $data = [], array $options = []): EntityInterfac public function newEmptyEntity(): EntityInterface { $class = $this->getResourceClass(); - $entity = new $class([], ['source' => $this->getRegistryAlias()]); - return $entity; + return new $class([], ['source' => $this->getRegistryAlias()]); } /** @@ -1264,16 +1323,19 @@ public function buildRules(RulesChecker $rules): RulesChecker * Returns a handy representation of this endpoint * * @return array + * @throws \Exception When the name of this endpoint could not be determined */ - public function __debugInfo() + public function __debugInfo(): array { + $connectionName = $this->getConnection()->configName(); + return [ 'registryAlias' => $this->getRegistryAlias(), 'alias' => $this->getAlias(), 'endpoint' => $this->getName(), 'resourceClass' => $this->getResourceClass(), 'defaultConnection' => $this->defaultConnectionName(), - 'connectionName' => $this->getConnection()->configName(), + 'connectionName' => $connectionName, 'inflector' => $this->getInflectionMethod(), ]; } @@ -1282,9 +1344,10 @@ public function __debugInfo() * Set the endpoint alias * * @param string $alias Alias for this endpoint - * @return $this + * @return self + * @psalm-suppress LessSpecificImplementedReturnType */ - public function setAlias($alias) + public function setAlias(string $alias): Endpoint { $this->_alias = $alias; diff --git a/src/Model/EndpointLocator.php b/src/Model/EndpointLocator.php index 7e8081f..9ac8c92 100644 --- a/src/Model/EndpointLocator.php +++ b/src/Model/EndpointLocator.php @@ -10,6 +10,7 @@ use Cake\Datasource\RepositoryInterface; use Cake\Utility\Inflector; use Muffin\Webservice\Datasource\Connection; +use function Cake\Core\pluginSplit; /** * Class EndpointLocator @@ -22,8 +23,8 @@ class EndpointLocator extends AbstractLocator * @param string $alias The alias to set. * @param \Muffin\Webservice\Model\Endpoint $repository The repository to set. * @return \Muffin\Webservice\Model\Endpoint - * @psalm-suppress MoreSpecificImplementedParamType * @psalm-suppress MoreSpecificReturnType + * @psalm-suppress MoreSpecificImplementedParamType Not a nice solution, but in this plugin, we only support Endpoints */ public function set(string $alias, RepositoryInterface $repository): Endpoint { @@ -40,8 +41,14 @@ public function set(string $alias, RepositoryInterface $repository): Endpoint */ public function get(string $alias, array $options = []): Endpoint { - /** @var \Muffin\Webservice\Model\Endpoint */ - return parent::get($alias, $options); + $parentRes = parent::get($alias, $options); + + assert( + $parentRes instanceof Endpoint, + 'The repository found is not of type Endpoint, but a different type implementing RepositoryInterface' + ); + + return $parentRes; } /** @@ -51,7 +58,7 @@ public function get(string $alias, array $options = []): Endpoint * @param array $options The alias to check for. * @return \Muffin\Webservice\Model\Endpoint */ - protected function createInstance(string $alias, array $options) + protected function createInstance(string $alias, array $options): Endpoint { [, $classAlias] = pluginSplit($alias); $options = ['alias' => $classAlias] + $options; @@ -63,7 +70,7 @@ protected function createInstance(string $alias, array $options) if ($className) { $options['className'] = $className; } else { - if (!isset($options['endpoint']) && strpos($options['className'], '\\') === false) { + if (!isset($options['endpoint']) && !str_contains($options['className'], '\\')) { [, $endpoint] = pluginSplit($options['className']); $options['endpoint'] = Inflector::underscore($endpoint); } @@ -74,11 +81,11 @@ protected function createInstance(string $alias, array $options) if ($options['className'] !== Endpoint::class) { $connectionName = $options['className']::defaultConnectionName(); } else { - if (strpos($alias, '.') === false) { + if (!str_contains($alias, '.')) { $connectionName = 'webservice'; } else { - /** @psalm-suppress PossiblyNullArgument */ - $pluginParts = explode('/', pluginSplit($alias)[0]); + /** @psalm-suppress PossiblyNullArgument Not clean, but cannot happen with incorrect configuration and was not a problem before **/ + $pluginParts = explode('/', pluginSplit($alias)[0]); /* @phpstan-ignore-line */ $connectionName = Inflector::underscore(end($pluginParts)); } } @@ -111,7 +118,6 @@ protected function getConnection(string $connectionName): Connection $message = $e->getMessage() . ' You can override Endpoint::defaultConnectionName() to return the connection name you want.'; - /** @psalm-suppress PossiblyInvalidArgument */ throw new MissingDatasourceConfigException($message, $e->getCode(), $e->getPrevious()); } } diff --git a/src/Model/Exception/MissingEndpointSchemaException.php b/src/Model/Exception/MissingEndpointSchemaException.php index a1a4891..1ecc202 100644 --- a/src/Model/Exception/MissingEndpointSchemaException.php +++ b/src/Model/Exception/MissingEndpointSchemaException.php @@ -12,5 +12,5 @@ class MissingEndpointSchemaException extends CakeException * * @var string */ - protected $_messageTemplate = 'Missing schema %s or webservice %s describe implementation'; + protected string $_messageTemplate = 'Missing schema %s or webservice %s describe implementation'; } diff --git a/src/Model/Exception/MissingResourceClassException.php b/src/Model/Exception/MissingResourceClassException.php index 4acacd6..5ee6c97 100644 --- a/src/Model/Exception/MissingResourceClassException.php +++ b/src/Model/Exception/MissingResourceClassException.php @@ -7,5 +7,5 @@ class MissingResourceClassException extends CakeException { - protected $_messageTemplate = 'Resource class %s could not be found.'; + protected string $_messageTemplate = 'Resource class %s could not be found.'; } diff --git a/src/Model/Resource.php b/src/Model/Resource.php index 17e3cec..cc2fec5 100644 --- a/src/Model/Resource.php +++ b/src/Model/Resource.php @@ -7,6 +7,9 @@ use Cake\Datasource\EntityTrait; use Cake\Datasource\InvalidPropertyInterface; +/** + * @psalm-consistent-constructor + */ class Resource implements EntityInterface, InvalidPropertyInterface { use EntityTrait; diff --git a/src/Model/ResourceBasedEntityInterface.php b/src/Model/ResourceBasedEntityInterface.php index 66b1d37..cd2d9a4 100644 --- a/src/Model/ResourceBasedEntityInterface.php +++ b/src/Model/ResourceBasedEntityInterface.php @@ -20,5 +20,5 @@ public function applyResource(Resource $resource): void; * @param array $options The options to pass to the constructor * @return self */ - public static function createFromResource(Resource $resource, array $options = []); + public static function createFromResource(Resource $resource, array $options = []): self; } diff --git a/src/Model/ResourceBasedEntityTrait.php b/src/Model/ResourceBasedEntityTrait.php index 45a5576..b173b66 100644 --- a/src/Model/ResourceBasedEntityTrait.php +++ b/src/Model/ResourceBasedEntityTrait.php @@ -23,7 +23,7 @@ public function applyResource(Resource $resource): void * @param array $options The options to pass to the constructor * @return self */ - public static function createFromResource(Resource $resource, array $options = []) + public static function createFromResource(Resource $resource, array $options = []): self { $entity = new self(); diff --git a/src/Webservice/Driver/AbstractDriver.php b/src/Webservice/Driver/AbstractDriver.php index 1662168..a955407 100644 --- a/src/Webservice/Driver/AbstractDriver.php +++ b/src/Webservice/Driver/AbstractDriver.php @@ -12,6 +12,7 @@ use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerInterface; +use function Cake\Core\pluginSplit; abstract class AbstractDriver implements LoggerAwareInterface { @@ -23,28 +24,28 @@ abstract class AbstractDriver implements LoggerAwareInterface * * @var object */ - protected $_client; + protected object $_client; /** * Default config * * @var array */ - protected $_defaultConfig = []; + protected array $_defaultConfig = []; /** * Whatever queries should be logged * * @var bool */ - protected $_logQueries = false; + protected bool $_logQueries = false; /** * The list of webservices to be used * * @var array */ - protected $_webservices = []; + protected array $_webservices = []; /** * Constructor. @@ -71,9 +72,9 @@ abstract public function initialize(): void; * Set the client instance this driver will use to make requests * * @param object $client Client instance - * @return $this + * @return self */ - public function setClient(object $client) + public function setClient(object $client): AbstractDriver { $this->_client = $client; @@ -83,9 +84,9 @@ public function setClient(object $client) /** * Get the client instance configured for this driver * - * @return object + * @return object|null */ - public function getClient(): object + public function getClient(): ?object { return $this->_client; } @@ -95,9 +96,9 @@ public function getClient(): object * * @param string $name The registry alias for the webservice instance * @param \Muffin\Webservice\Webservice\WebserviceInterface $webservice Instance of the webservice - * @return $this + * @return self */ - public function setWebservice(string $name, WebserviceInterface $webservice) + public function setWebservice(string $name, WebserviceInterface $webservice): AbstractDriver { $this->_webservices[$name] = $webservice; @@ -132,14 +133,12 @@ public function getWebservice(string $name): WebserviceInterface * Sets a logger * * @param \Psr\Log\LoggerInterface $logger Logger object - * @return $this + * @return void * @psalm-suppress ImplementedReturnTypeMismatch */ - public function setLogger(LoggerInterface $logger) + public function setLogger(LoggerInterface $logger): void { $this->logger = $logger; - - return $this; } /** @@ -165,9 +164,9 @@ public function configName(): string /** * Enable query logging for the driver * - * @return $this + * @return self */ - public function enableQueryLogging() + public function enableQueryLogging(): AbstractDriver { $this->_logQueries = true; @@ -177,9 +176,9 @@ public function enableQueryLogging() /** * Disable query logging for the driver * - * @return $this + * @return self */ - public function disableQueryLogging() + public function disableQueryLogging(): AbstractDriver { $this->_logQueries = false; @@ -205,15 +204,17 @@ public function isQueryLoggingEnabled(): bool * @throws \RuntimeException If the client object has not been initialized. * @throws \Muffin\Webservice\Webservice\Exception\UnimplementedWebserviceMethodException If the method does not exist in the client. */ - public function __call($method, $args) + public function __call(string $method, array $args): mixed { - if (!method_exists($this->getClient(), $method)) { + /** @psalm-suppress PossiblyNullArgument Only the left expression is executed if the getClient returns null **/ + if ($this->getClient() === null || !method_exists($this->getClient(), $method)) { throw new UnimplementedWebserviceMethodException([ 'name' => $this->getConfig('name'), 'method' => $method, ]); } + /* @phpstan-ignore-next-line This is supported behavior for now: https://www.php.net/manual/en/function.call-user-func-array.php (example 1) */ return call_user_func_array([$this->_client, $method], $args); } @@ -222,7 +223,7 @@ public function __call($method, $args) * * @return array */ - public function __debugInfo() + public function __debugInfo(): array { return [ 'client' => $this->getClient(), @@ -254,7 +255,7 @@ protected function _createWebservice(string $className, array $options = []): We $fallbackWebserviceClass = end($namespaceParts); [$pluginName] = pluginSplit($className); - if ($pluginName) { + if ($pluginName !== null) { $fallbackWebserviceClass = $pluginName . '.' . $fallbackWebserviceClass; } diff --git a/src/Webservice/Exception/MissingDriverException.php b/src/Webservice/Exception/MissingDriverException.php index be71b8d..debb604 100644 --- a/src/Webservice/Exception/MissingDriverException.php +++ b/src/Webservice/Exception/MissingDriverException.php @@ -12,5 +12,5 @@ class MissingDriverException extends CakeException * * @var string */ - protected $_messageTemplate = 'Webservice driver %s could not be found.'; + protected string $_messageTemplate = 'Webservice driver %s could not be found.'; } diff --git a/src/Webservice/Exception/MissingWebserviceClassException.php b/src/Webservice/Exception/MissingWebserviceClassException.php index 9cdb7bc..0844421 100644 --- a/src/Webservice/Exception/MissingWebserviceClassException.php +++ b/src/Webservice/Exception/MissingWebserviceClassException.php @@ -7,5 +7,5 @@ class MissingWebserviceClassException extends CakeException { - protected $_messageTemplate = 'Webservice class %s (and fallback %s) could not be found.'; + protected string $_messageTemplate = 'Webservice class %s (and fallback %s) could not be found.'; } diff --git a/src/Webservice/Exception/UnexpectedDriverException.php b/src/Webservice/Exception/UnexpectedDriverException.php index 3138a96..b4dc137 100644 --- a/src/Webservice/Exception/UnexpectedDriverException.php +++ b/src/Webservice/Exception/UnexpectedDriverException.php @@ -12,5 +12,6 @@ class UnexpectedDriverException extends CakeException * * @var string */ - protected $_messageTemplate = 'Driver (`%s`) should extend `Muffin\Webservice\Webservice\Driver\AbstractDriver`'; + protected string $_messageTemplate + = 'Driver (`%s`) should extend `Muffin\Webservice\Webservice\Driver\AbstractDriver`'; } diff --git a/src/Webservice/Exception/UnimplementedDriverMethodException.php b/src/Webservice/Exception/UnimplementedDriverMethodException.php index 19ea970..333d1bb 100644 --- a/src/Webservice/Exception/UnimplementedDriverMethodException.php +++ b/src/Webservice/Exception/UnimplementedDriverMethodException.php @@ -12,5 +12,5 @@ class UnimplementedDriverMethodException extends CakeException * * @var string */ - protected $_messageTemplate = 'Driver (`%s`) does not implement `%s`'; + protected string $_messageTemplate = 'Driver (`%s`) does not implement `%s`'; } diff --git a/src/Webservice/Exception/UnimplementedWebserviceMethodException.php b/src/Webservice/Exception/UnimplementedWebserviceMethodException.php index 840521e..ff5e5e9 100644 --- a/src/Webservice/Exception/UnimplementedWebserviceMethodException.php +++ b/src/Webservice/Exception/UnimplementedWebserviceMethodException.php @@ -12,5 +12,5 @@ class UnimplementedWebserviceMethodException extends CakeException * * @var string */ - protected $_messageTemplate = 'Webservice %s does not implement %s'; + protected string $_messageTemplate = 'Webservice %s does not implement %s'; } diff --git a/src/Webservice/Webservice.php b/src/Webservice/Webservice.php index 54d3c6f..08d2637 100644 --- a/src/Webservice/Webservice.php +++ b/src/Webservice/Webservice.php @@ -7,6 +7,7 @@ use Cake\Utility\Inflector; use Cake\Utility\Text; use Muffin\Webservice\Datasource\Query; +use Muffin\Webservice\Datasource\ResultSet; use Muffin\Webservice\Datasource\Schema; use Muffin\Webservice\Model\Endpoint; use Muffin\Webservice\Model\Exception\MissingEndpointSchemaException; @@ -15,6 +16,7 @@ use Muffin\Webservice\Webservice\Exception\UnimplementedWebserviceMethodException; use Psr\Log\LoggerInterface; use RuntimeException; +use function Cake\Core\pluginSplit; /** * Basic implementation of a webservice @@ -26,23 +28,23 @@ abstract class Webservice implements WebserviceInterface /** * The driver to use to communicate with the webservice * - * @var \Muffin\Webservice\Webservice\Driver\AbstractDriver + * @var \Muffin\Webservice\Webservice\Driver\AbstractDriver|null */ - protected $_driver; + protected ?AbstractDriver $_driver = null; /** * The webservice to call * - * @var string + * @var string|null */ - protected $_endpoint; + protected ?string $_endpoint = null; /** * A list of nested resources with their path and needed conditions * * @var array */ - protected $_nestedResources = []; + protected array $_nestedResources = []; /** * Construct the webservice @@ -74,9 +76,9 @@ public function initialize(): void * Set the webservice driver and return the instance for chaining * * @param \Muffin\Webservice\Webservice\Driver\AbstractDriver $driver Instance of the driver - * @return $this + * @return self */ - public function setDriver(AbstractDriver $driver) + public function setDriver(AbstractDriver $driver): Webservice { $this->_driver = $driver; @@ -101,9 +103,9 @@ public function getDriver(): AbstractDriver * Set the endpoint path this webservice uses * * @param string $endpoint Endpoint path - * @return $this + * @return self */ - public function setEndpoint(string $endpoint) + public function setEndpoint(string $endpoint): Webservice { $this->_endpoint = $endpoint; @@ -113,9 +115,9 @@ public function setEndpoint(string $endpoint) /** * Get the endpoint path for this webservice * - * @return string + * @return string|null */ - public function getEndpoint(): string + public function getEndpoint(): ?string { return $this->_endpoint; } @@ -161,9 +163,9 @@ public function nestedResource(array $conditions): ?string * * @param \Muffin\Webservice\Datasource\Query $query The query to execute * @param array $options The options to use - * @return bool|int|\Muffin\Webservice\Model\Resource|\Muffin\Webservice\Datasource\ResultSet + * @return \Muffin\Webservice\Model\Resource|\Muffin\Webservice\Datasource\ResultSet|int|bool */ - public function execute(Query $query, array $options = []) + public function execute(Query $query, array $options = []): bool|int|Resource|ResultSet { $result = $this->_executeQuery($query, $options); @@ -185,7 +187,7 @@ public function execute(Query $query, array $options = []) public function describe(string $endpoint): Schema { $shortName = App::shortName(static::class, 'Webservice', 'Webservice'); - [$plugin, $name] = pluginSplit($shortName); + [$plugin] = pluginSplit($shortName); $endpoint = Inflector::classify(str_replace('-', '_', $endpoint)); $schemaShortName = implode('.', array_filter([$plugin, $endpoint])); @@ -206,24 +208,17 @@ public function describe(string $endpoint): Schema * * @param \Muffin\Webservice\Datasource\Query $query The query to execute * @param array $options The options to use - * @return bool|int|\Muffin\Webservice\Model\Resource|\Muffin\Webservice\Datasource\ResultSet - * @psalm-suppress NullableReturnStatement - * @psalm-suppress InvalidNullableReturnType + * @return \Muffin\Webservice\Model\Resource|\Muffin\Webservice\Datasource\ResultSet|int|bool */ - protected function _executeQuery(Query $query, array $options = []) + protected function _executeQuery(Query $query, array $options = []): bool|int|Resource|ResultSet { - switch ($query->clause('action')) { - case Query::ACTION_CREATE: - return $this->_executeCreateQuery($query, $options); - case Query::ACTION_READ: - return $this->_executeReadQuery($query, $options); - case Query::ACTION_UPDATE: - return $this->_executeUpdateQuery($query, $options); - case Query::ACTION_DELETE: - return $this->_executeDeleteQuery($query, $options); - } - - return false; + return match ($query->clause('action')) { + Query::ACTION_CREATE => $this->_executeCreateQuery($query, $options), + Query::ACTION_READ => $this->_executeReadQuery($query, $options), + Query::ACTION_UPDATE => $this->_executeUpdateQuery($query, $options), + Query::ACTION_DELETE => $this->_executeDeleteQuery($query, $options), + default => false, + }; } /** @@ -231,11 +226,11 @@ protected function _executeQuery(Query $query, array $options = []) * * @param \Muffin\Webservice\Datasource\Query $query The query to execute * @param array $options The options to use - * @return bool|\Muffin\Webservice\Model\Resource + * @return \Muffin\Webservice\Model\Resource|bool * @throws \Muffin\Webservice\Webservice\Exception\UnimplementedWebserviceMethodException When this method has not been * implemented into userland classes */ - protected function _executeCreateQuery(Query $query, array $options = []) + protected function _executeCreateQuery(Query $query, array $options = []): bool|Resource { throw new UnimplementedWebserviceMethodException([ 'name' => static::class, @@ -248,11 +243,11 @@ protected function _executeCreateQuery(Query $query, array $options = []) * * @param \Muffin\Webservice\Datasource\Query $query The query to execute * @param array $options The options to use - * @return bool|\Muffin\Webservice\Datasource\ResultSet + * @return \Muffin\Webservice\Datasource\ResultSet|bool * @throws \Muffin\Webservice\Webservice\Exception\UnimplementedWebserviceMethodException When this method has not been * implemented into userland classes */ - protected function _executeReadQuery(Query $query, array $options = []) + protected function _executeReadQuery(Query $query, array $options = []): bool|ResultSet { throw new UnimplementedWebserviceMethodException([ 'name' => static::class, @@ -265,11 +260,11 @@ protected function _executeReadQuery(Query $query, array $options = []) * * @param \Muffin\Webservice\Datasource\Query $query The query to execute * @param array $options The options to use - * @return int|bool|\Muffin\Webservice\Model\Resource + * @return \Muffin\Webservice\Model\Resource|int|bool * @throws \Muffin\Webservice\Webservice\Exception\UnimplementedWebserviceMethodException When this method has not been * implemented into userland classes */ - protected function _executeUpdateQuery(Query $query, array $options = []) + protected function _executeUpdateQuery(Query $query, array $options = []): int|bool|Resource { throw new UnimplementedWebserviceMethodException([ 'name' => static::class, @@ -286,7 +281,7 @@ protected function _executeUpdateQuery(Query $query, array $options = []) * @throws \Muffin\Webservice\Webservice\Exception\UnimplementedWebserviceMethodException When this method has not been * implemented into userland classes */ - protected function _executeDeleteQuery(Query $query, array $options = []) + protected function _executeDeleteQuery(Query $query, array $options = []): int|bool { throw new UnimplementedWebserviceMethodException([ 'name' => static::class, @@ -306,6 +301,7 @@ protected function _executeDeleteQuery(Query $query, array $options = []) */ protected function _createResource(string $resourceClass, array $properties = []): Resource { + /* @phpstan-ignore-next-line See psalm suppress comments */ return new $resourceClass($properties, [ 'markClean' => true, 'markNew' => false, @@ -326,7 +322,7 @@ protected function _logQuery(Query $query, LoggerInterface $logger): void } $logger->debug($query->getEndpoint()->getName(), [ - 'params' => $query->where(), + 'params' => $query->clause('where'), ]); } @@ -335,7 +331,7 @@ protected function _logQuery(Query $query, LoggerInterface $logger): void * * @param \Muffin\Webservice\Model\Endpoint $endpoint The endpoint class to use * @param array $results Array of results from the API - * @return \Muffin\Webservice\Model\Resource[] Array of resource objects + * @return array<\Muffin\Webservice\Model\Resource> Array of resource objects */ protected function _transformResults(Endpoint $endpoint, array $results): array { @@ -370,7 +366,7 @@ protected function _transformResource(Endpoint $endpoint, array $result): Resour * * @return array */ - public function __debugInfo() + public function __debugInfo(): array { return [ 'driver' => $this->_driver, diff --git a/src/Webservice/WebserviceInterface.php b/src/Webservice/WebserviceInterface.php index 6c41616..32d4d9c 100644 --- a/src/Webservice/WebserviceInterface.php +++ b/src/Webservice/WebserviceInterface.php @@ -4,7 +4,9 @@ namespace Muffin\Webservice\Webservice; use Muffin\Webservice\Datasource\Query; +use Muffin\Webservice\Datasource\ResultSet; use Muffin\Webservice\Datasource\Schema; +use Muffin\Webservice\Model\Resource; /** * Describes a webservice used to call a API @@ -18,9 +20,9 @@ interface WebserviceInterface * * @param \Muffin\Webservice\Datasource\Query $query The query to execute * @param array $options The options to use - * @return bool|int|\Muffin\Webservice\Model\Resource|\Muffin\Webservice\Datasource\ResultSet + * @return \Muffin\Webservice\Model\Resource|\Muffin\Webservice\Datasource\ResultSet|int|bool */ - public function execute(Query $query, array $options = []); + public function execute(Query $query, array $options = []): bool|int|Resource|ResultSet; /** * Returns a schema for the provided endpoint diff --git a/src/Plugin.php b/src/WebservicePlugin.php similarity index 77% rename from src/Plugin.php rename to src/WebservicePlugin.php index b08531c..4200825 100644 --- a/src/Plugin.php +++ b/src/WebservicePlugin.php @@ -8,28 +8,28 @@ use Cake\Datasource\FactoryLocator; use Muffin\Webservice\Model\EndpointLocator; -class Plugin extends BasePlugin +class WebservicePlugin extends BasePlugin { /** * Disable routes hook. * * @var bool */ - protected $routesEnabled = false; + protected bool $routesEnabled = false; /** * Disable middleware hook. * * @var bool */ - protected $middlewareEnabled = false; + protected bool $middlewareEnabled = false; /** * Disable console hook. * * @var bool */ - protected $consoleEnabled = false; + protected bool $consoleEnabled = false; /** * @inheritDoc diff --git a/tests/TestCase/AbstractDriverTest.php b/tests/TestCase/AbstractDriverTest.php index 5f43f40..43ded93 100644 --- a/tests/TestCase/AbstractDriverTest.php +++ b/tests/TestCase/AbstractDriverTest.php @@ -1,11 +1,12 @@ setClient($client); diff --git a/tests/TestCase/BootstrapTest.php b/tests/TestCase/BootstrapTest.php index d7e070e..88af0af 100644 --- a/tests/TestCase/BootstrapTest.php +++ b/tests/TestCase/BootstrapTest.php @@ -3,7 +3,6 @@ namespace Muffin\Webservice\Test\TestCase; -use Cake\Controller\Controller; use Cake\Datasource\ConnectionManager; use Cake\Datasource\FactoryLocator; use Cake\TestSuite\TestCase; @@ -24,16 +23,15 @@ public function setUp(): void * * @return void */ - public function testLoadingEndpointWithLoadModel() + public function testLoadingEndpointWithLocator() { $connection = new Connection([ 'name' => 'test', 'service' => 'Test', ]); ConnectionManager::setConfig('test_app', $connection); - - $controller = new Controller(); - $endpoint = $controller->loadModel('Test', 'Endpoint'); + $endpointlocator = new EndpointLocator(); + $endpoint = $endpointlocator->get('Test'); $this->assertInstanceOf(TestEndpoint::class, $endpoint); $this->assertEquals('Test', $endpoint->getAlias()); diff --git a/tests/TestCase/ConnectionTest.php b/tests/TestCase/ConnectionTest.php index 7347738..65c49ef 100644 --- a/tests/TestCase/ConnectionTest.php +++ b/tests/TestCase/ConnectionTest.php @@ -11,9 +11,9 @@ class ConnectionTest extends TestCase { /** - * @var Connection + * @var Connection|null */ - public $connection; + public ?Connection $connection; public function setUp(): void { @@ -31,7 +31,7 @@ public function testConstructorMissingDriver() new Connection([ 'name' => 'test', - 'service' => 'MissingDriver', + 'service' => 'Missing', ]); } @@ -43,4 +43,9 @@ public function testConstructorNoDriver() 'name' => 'test', ]); } + + public function testConfigName() + { + $this->assertEquals('test', $this->connection->configName()); + } } diff --git a/tests/TestCase/MarshallerTest.php b/tests/TestCase/MarshallerTest.php index fbad5d6..9de7bfe 100644 --- a/tests/TestCase/MarshallerTest.php +++ b/tests/TestCase/MarshallerTest.php @@ -14,9 +14,9 @@ class MarshallerTest extends TestCase { /** - * @var Marshaller + * @var Marshaller|null */ - private $marshaller; + private ?Marshaller $marshaller; /** * Create a marshaller instance for testing @@ -45,7 +45,7 @@ public function testOne() [ 'title' => 'Testing one', 'body' => 'Testing the marshaller', - ] + ], ); $this->assertInstanceOf(Resource::class, $result); @@ -63,6 +63,7 @@ public function testOneWithFieldList() ], [ 'fieldList' => ['title'], + 'validate' => false, ] ); @@ -80,6 +81,7 @@ public function testOneWithAccessibleFields() ], [ 'accessibleFields' => ['body' => false], + 'validate' => false, ] ); @@ -97,6 +99,7 @@ public function testOneWithNoFields() ], [ 'fieldList' => [], + 'validate' => false, ] ); @@ -114,6 +117,7 @@ public function testOneWithNoAccessible() ], [ 'accessibleFields' => ['title' => false, 'body' => false], + 'validate' => false, ] ); @@ -137,6 +141,7 @@ public function testOneEnsuringFieldListBeforeAccessible() [ 'fieldList' => ['title', 'body'], 'accessibleFields' => ['title' => false, 'body' => false], + 'validate' => false, ] ); diff --git a/tests/TestCase/Model/Endpoint/Schema/SchemaTest.php b/tests/TestCase/Model/Endpoint/Schema/SchemaTest.php index b040ce4..48a55e2 100644 --- a/tests/TestCase/Model/Endpoint/Schema/SchemaTest.php +++ b/tests/TestCase/Model/Endpoint/Schema/SchemaTest.php @@ -4,14 +4,15 @@ namespace Muffin\Webservice\Test\TestCase\Model\Endpoint\Schema; use Cake\TestSuite\TestCase; +use Muffin\Webservice\Model\Schema; use TestApp\Model\Endpoint\Schema\TestSchema; class SchemaTest extends TestCase { /** - * @var \Muffin\Webservice\Schema + * @var Schema|null */ - private $schema; + private ?Schema $schema; public function setUp(): void { @@ -20,7 +21,7 @@ public function setUp(): void public function testName() { - $this->assertEquals($this->schema->name(), 'test'); + $this->assertEquals('test', $this->schema->name()); } public function testAddColumn() diff --git a/tests/TestCase/Model/EndpointLocatorTest.php b/tests/TestCase/Model/EndpointLocatorTest.php index e64e524..71578e0 100644 --- a/tests/TestCase/Model/EndpointLocatorTest.php +++ b/tests/TestCase/Model/EndpointLocatorTest.php @@ -1,19 +1,21 @@ expectException(MissingDatasourceConfigException::class); $this->expectExceptionMessage( - 'The datasource configuration "non-existent" was not found.' + 'The datasource configuration `non-existent` was not found.' . ' You can override Endpoint::defaultConnectionName() to return the connection name you want.' ); @@ -93,13 +95,14 @@ public function testGetException() public function testGetWithExistingObject() { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('You cannot configure "First", it already exists in the registry.'); + $this->expectExceptionMessage('You cannot configure `First`, it already exists in the registry.'); $result = $this->Locator->get('First', [ 'className' => Endpoint::class, 'registryAlias' => 'First', 'connection' => 'test', ]); + // debug($result); $this->assertInstanceOf(Endpoint::class, $result); $this->Locator->get('First', ['registryAlias' => 'NotFirst']); diff --git a/tests/TestCase/Model/EndpointTest.php b/tests/TestCase/Model/EndpointTest.php index 8d26ce9..ab13cd7 100644 --- a/tests/TestCase/Model/EndpointTest.php +++ b/tests/TestCase/Model/EndpointTest.php @@ -3,6 +3,7 @@ namespace Muffin\Webservice\Test\TestCase\Model; +use AllowDynamicProperties; use BadMethodCallException; use Cake\Datasource\Exception\RecordNotFoundException; use Cake\Event\EventManager; @@ -20,17 +21,18 @@ use TestApp\Model\Endpoint\TestEndpoint; use TestApp\Webservice\TestWebservice; +#[AllowDynamicProperties] class EndpointTest extends TestCase { /** - * @var \Muffin\Webservice\Connection + * @var Connection|null */ - protected $connection; + protected ?Connection $connection; /** - * @var Endpoint + * @var Endpoint|null */ - protected $endpoint; + protected ?Endpoint $endpoint; /** * @inheritDoc @@ -50,7 +52,7 @@ public function setUp(): void ]); } - public function providerEndpointNames() + public static function providerEndpointNames(): array { return [ 'No inflector' => ['user-groups', null, 'user_groups'], @@ -65,7 +67,7 @@ public function providerEndpointNames() * @param string|null $inflector * @param string $expected */ - public function testEndpointName($name, $inflector, $expected) + public function testEndpointName(string $name, ?string $inflector, string $expected) { $endpoint = new Endpoint(['name' => $name, 'inflect' => $inflector]); $this->assertSame($expected, $endpoint->getName()); @@ -98,11 +100,15 @@ public function testFindByTitle() public function testFindList() { - $this->assertEquals([ + $this->assertEquals( + [ 1 => 'Hello World', 2 => 'New ORM', 3 => 'Webservices', - ], $this->endpoint->find('list')->toArray()); + ], + $this->endpoint->find('list')->toArray(), + 'Id => valueField' + ); $this->assertEquals([ 'Hello World' => 'Some text', @@ -111,7 +117,17 @@ public function testFindList() ], $this->endpoint->find('list', [ 'keyField' => 'title', 'valueField' => 'body', - ])->toArray()); + ])->toArray(), 'Find with options array'); + + $this->assertEquals([ + 'Hello World' => 'Some text', + 'New ORM' => 'Some more text', + 'Webservices' => 'Even more text', + ], $this->endpoint->find( + 'list', + keyField: 'title', + valueField: 'body', + )->toArray(), 'Find with named parameters'); } public function testGet() @@ -176,7 +192,7 @@ public function testUpdatingSave() $this->assertFalse($savedResource->isNew()); $newResource = $this->endpoint->get(2); - $this->assertEquals($newResource->title, 'New ORM for webservices'); + $this->assertEquals('New ORM for webservices', $newResource->title); } public function testDelete() diff --git a/tests/TestCase/Model/ResourceTest.php b/tests/TestCase/Model/ResourceTest.php index 7a9e888..b73e55a 100644 --- a/tests/TestCase/Model/ResourceTest.php +++ b/tests/TestCase/Model/ResourceTest.php @@ -1,7 +1,7 @@ assertEquals([ 'field' => 'value', - ], $this->query->set()); + ], $this->query->clause('set')); } public function testPage() @@ -174,7 +174,7 @@ public function testExecuteTwice() $mockWebservice->expects($this->once()) ->method('execute') - ->will($this->returnValue(new ResultSet([ + ->willReturn(new ResultSet([ new Resource([ 'id' => 1, 'title' => 'Hello World', @@ -187,7 +187,7 @@ public function testExecuteTwice() 'id' => 3, 'title' => 'Webservices', ]), - ], 3))); + ], 3)); $this->query ->setWebservice($mockWebservice) @@ -284,7 +284,7 @@ public function testSelectWithCallable() { $fields = ['id', 'username', 'email', 'biography']; - $callable = function (Query $query) use ($fields) { + $callable = function () use ($fields) { return $fields; }; $this->query->select($callable); diff --git a/tests/TestCase/ResultSetTest.php b/tests/TestCase/ResultSetTest.php index eb72e53..727f506 100644 --- a/tests/TestCase/ResultSetTest.php +++ b/tests/TestCase/ResultSetTest.php @@ -10,9 +10,9 @@ class ResultSetTest extends TestCase { /** - * @var ResultSet + * @var ResultSet|null */ - public $resultSet; + public ?ResultSet $resultSet; /** * @inheritDoc diff --git a/tests/TestCase/Webservice/WebserviceTest.php b/tests/TestCase/Webservice/WebserviceTest.php index 8dca7d7..4ab6c9b 100644 --- a/tests/TestCase/Webservice/WebserviceTest.php +++ b/tests/TestCase/Webservice/WebserviceTest.php @@ -8,15 +8,16 @@ use Muffin\Webservice\Model\Endpoint; use Muffin\Webservice\Model\Exception\MissingEndpointSchemaException; use Muffin\Webservice\Webservice\Exception\UnimplementedWebserviceMethodException; +use Muffin\Webservice\Webservice\Webservice; use TestApp\Webservice\Driver\Test; use TestApp\Webservice\TestWebservice; class WebserviceTest extends TestCase { /** - * @var \Muffin\Webservice\Webservice\Webservice + * @var \Muffin\Webservice\Webservice\Webservice|null */ - public $webservice; + public ?Webservice $webservice; /** * @inheritDoc @@ -159,7 +160,6 @@ public function testCreateResource() /** @var \Muffin\Webservice\Model\Resource $resource */ $resource = $this->webservice->createResource('\Muffin\Webservice\Model\Resource', []); - $this->assertInstanceOf('\Muffin\Webservice\Model\Resource', $resource); $this->assertFalse($resource->isNew()); $this->assertFalse($resource->isDirty()); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a6bb912..1e2a3fa 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -18,24 +18,24 @@ use Cake\Datasource\ConnectionManager; use Cake\Log\Log; use Muffin\Webservice\Datasource\Connection; -use TestApp\Webservice\Driver\Test as TestDriver; +use TestApp\Webservice\Driver\Test; require_once 'vendor/autoload.php'; // Path constants to a few helpful things. define('ROOT', dirname(__DIR__) . DS); -define('CAKE_CORE_INCLUDE_PATH', ROOT . 'vendor' . DS . 'cakephp' . DS . 'cakephp'); -define('CORE_PATH', ROOT . 'vendor' . DS . 'cakephp' . DS . 'cakephp' . DS); -define('CAKE', CORE_PATH . 'src' . DS); -define('TESTS', ROOT . 'tests'); -define('APP', ROOT . 'tests' . DS . 'test_app' . DS); -define('APP_DIR', 'test_app'); -define('WEBROOT_DIR', 'webroot'); -define('WWW_ROOT', APP . 'webroot' . DS); +const CAKE_CORE_INCLUDE_PATH = ROOT . 'vendor' . DS . 'cakephp' . DS . 'cakephp'; +const CORE_PATH = ROOT . 'vendor' . DS . 'cakephp' . DS . 'cakephp' . DS; +const CAKE = CORE_PATH . 'src' . DS; +const TESTS = ROOT . 'tests'; +const APP = ROOT . 'tests' . DS . 'test_app' . DS; +const APP_DIR = 'test_app'; +const WEBROOT_DIR = 'webroot'; +const WWW_ROOT = APP . 'webroot' . DS; define('TMP', sys_get_temp_dir() . DS); -define('CONFIG', APP . 'config' . DS); -define('CACHE', TMP); -define('LOGS', TMP); +const CONFIG = APP . 'config' . DS; +const CACHE = TMP; +const LOGS = TMP; require_once CORE_PATH . 'config/bootstrap.php'; @@ -88,8 +88,8 @@ ConnectionManager::setConfig('test', [ 'className' => Connection::class, - 'driver' => TestDriver::class, -] + ConnectionManager::parseDsn(env('DB_DSN'))); + 'driver' => Test::class, +] + ConnectionManager::parseDsn(getenv('DB_DSN'))); Log::setConfig([ 'debug' => [ diff --git a/tests/test_app/plugins/SomeVendor/SomePlugin/src/Webservice/SomePluginWebservice.php b/tests/test_app/plugins/SomeVendor/SomePlugin/src/Webservice/SomePluginWebservice.php index 41d20e3..73b8553 100644 --- a/tests/test_app/plugins/SomeVendor/SomePlugin/src/Webservice/SomePluginWebservice.php +++ b/tests/test_app/plugins/SomeVendor/SomePlugin/src/Webservice/SomePluginWebservice.php @@ -4,16 +4,20 @@ namespace SomeVendor\SomePlugin\Webservice; use Muffin\Webservice\Model\Endpoint; +use Muffin\Webservice\Model\Resource; use Muffin\Webservice\Webservice\Webservice; class SomePluginWebservice extends Webservice { - public function createResource($resourceClass, array $properties = []) + public function createResource($resourceClass, array $properties = []): Resource { return $this->_createResource($resourceClass, $properties); } - public function transformResults(Endpoint $endpoint, array $results) + /** + * @return Resource[] + */ + public function transformResults(Endpoint $endpoint, array $results): array { return $this->_transformResults($endpoint, $results); } diff --git a/tests/test_app/plugins/TestPlugin/src/Webservice/TestPluginWebservice.php b/tests/test_app/plugins/TestPlugin/src/Webservice/TestPluginWebservice.php index df2a152..ef5c5b8 100644 --- a/tests/test_app/plugins/TestPlugin/src/Webservice/TestPluginWebservice.php +++ b/tests/test_app/plugins/TestPlugin/src/Webservice/TestPluginWebservice.php @@ -4,16 +4,20 @@ namespace TestPlugin\Webservice; use Muffin\Webservice\Model\Endpoint; +use Muffin\Webservice\Model\Resource; use Muffin\Webservice\Webservice\Webservice; class TestPluginWebservice extends Webservice { - public function createResource($resourceClass, array $properties = []) + public function createResource($resourceClass, array $properties = []): Resource { return $this->_createResource($resourceClass, $properties); } - public function transformResults(Endpoint $endpoint, array $results) + /** + * @return Resource[] + */ + public function transformResults(Endpoint $endpoint, array $results): array { return $this->_transformResults($endpoint, $results); } diff --git a/tests/test_app/src/Model/Endpoint/TestEndpoint.php b/tests/test_app/src/Model/Endpoint/TestEndpoint.php index f48fbcd..2822160 100644 --- a/tests/test_app/src/Model/Endpoint/TestEndpoint.php +++ b/tests/test_app/src/Model/Endpoint/TestEndpoint.php @@ -32,7 +32,7 @@ public function validationDefault(Validator $validator): Validator * * @return true */ - public function findExamples() + public function findExamples(): bool { return true; } diff --git a/tests/test_app/src/Webservice/EndpointTestWebservice.php b/tests/test_app/src/Webservice/EndpointTestWebservice.php index 60de65b..158a559 100644 --- a/tests/test_app/src/Webservice/EndpointTestWebservice.php +++ b/tests/test_app/src/Webservice/EndpointTestWebservice.php @@ -10,7 +10,10 @@ class EndpointTestWebservice extends Webservice { - protected $resources; + /** + * @var Resource[] + */ + protected array $resources; public function initialize(): void { @@ -44,26 +47,28 @@ public function initialize(): void ]; } - protected function _executeCreateQuery(Query $query, array $options = []) + protected function _executeCreateQuery(Query $query, array $options = []): bool|Resource { - $fields = $query->set(); + $fields = $query->clause('set'); if (!is_numeric($fields['id'])) { return false; } - $this->resources[] = new Resource($fields, [ + $resource = new Resource($fields, [ 'markNew' => false, 'markClean' => true, ]); + $this->resources[] = $resource; - return true; + return $resource; } - protected function _executeReadQuery(Query $query, array $options = []) + protected function _executeReadQuery(Query $query, array $options = []): bool|ResultSet { - if (!empty($query->where()['id'])) { - $index = $this->conditionsToIndex($query->where()); + $whereConditions = $query->clause('where'); + if (!empty($whereConditions['id'])) { + $index = $this->conditionsToIndex($whereConditions); if (!isset($this->resources[$index])) { return new ResultSet([], 0); @@ -73,11 +78,12 @@ protected function _executeReadQuery(Query $query, array $options = []) $this->resources[$index], ], 1); } - if (isset($query->where()[$query->getEndpoint()->aliasField('title')])) { + $conditions = $this->extractConditions($query->getOptions()); + if (isset($conditions[$query->getEndpoint()->aliasField('title')])) { $resources = []; foreach ($this->resources as $resource) { - if ($resource->title !== $query->where()[$query->getEndpoint()->aliasField('title')]) { + if ($resource->title !== $conditions[$query->getEndpoint()->aliasField('title')]) { continue; } @@ -90,18 +96,18 @@ protected function _executeReadQuery(Query $query, array $options = []) return new ResultSet($this->resources, count($this->resources)); } - protected function _executeUpdateQuery(Query $query, array $options = []) + protected function _executeUpdateQuery(Query $query, array $options = []): int|bool|Resource { - $this->resources[$this->conditionsToIndex($query->where())]->set($query->set()); + $this->resources[$this->conditionsToIndex($query->clause('where'))]->set($query->clause('set')); - $this->resources[$this->conditionsToIndex($query->where())]->clean(); + $this->resources[$this->conditionsToIndex($query->clause('where'))]->clean(); return 1; } - protected function _executeDeleteQuery(Query $query, array $options = []) + protected function _executeDeleteQuery(Query $query, array $options = []): int|bool { - $conditions = $query->where(); + $conditions = $query->clause('where'); if (is_int($conditions['id'])) { $exists = isset($this->resources[$this->conditionsToIndex($conditions)]); @@ -127,8 +133,19 @@ protected function _executeDeleteQuery(Query $query, array $options = []) return 0; } - public function conditionsToIndex(array $conditions) + public function conditionsToIndex(array $conditions): int { return $conditions['id'] - 1; } + + public function extractConditions(array $options) + { + foreach ($options as $option) { + if (isset($option['conditions'])) { + return $option['conditions']; + } + } + + return null; + } } diff --git a/tests/test_app/src/Webservice/Logger.php b/tests/test_app/src/Webservice/Logger.php index 36e0c55..f323059 100644 --- a/tests/test_app/src/Webservice/Logger.php +++ b/tests/test_app/src/Webservice/Logger.php @@ -4,6 +4,7 @@ namespace TestApp\Webservice; use Psr\Log\LoggerInterface; +use Stringable; /** * @package MuffinWebservice @@ -15,11 +16,11 @@ class Logger implements LoggerInterface /** * System is unusable. * - * @param string $message + * @param \Stringable|string $message * @param array $context * @return void */ - public function emergency($message, array $context = []) + public function emergency(string|Stringable $message, array $context = []): void { } @@ -29,11 +30,11 @@ public function emergency($message, array $context = []) * Example: Entire website down, database unavailable, etc. This should * trigger the SMS alerts and wake you up. * - * @param string $message + * @param \Stringable|string $message * @param array $context * @return void */ - public function alert($message, array $context = []) + public function alert(string|Stringable $message, array $context = []): void { } @@ -42,11 +43,11 @@ public function alert($message, array $context = []) * * Example: Application component unavailable, unexpected exception. * - * @param string $message + * @param \Stringable|string $message * @param array $context * @return void */ - public function critical($message, array $context = []) + public function critical(string|Stringable $message, array $context = []): void { } @@ -54,11 +55,11 @@ public function critical($message, array $context = []) * Runtime errors that do not require immediate action but should typically * be logged and monitored. * - * @param string $message + * @param \Stringable|string $message * @param array $context * @return void */ - public function error($message, array $context = []) + public function error(string|Stringable $message, array $context = []): void { } @@ -68,22 +69,22 @@ public function error($message, array $context = []) * Example: Use of deprecated APIs, poor use of an API, undesirable things * that are not necessarily wrong. * - * @param string $message + * @param string|\Stringable $message * @param array $context * @return void */ - public function warning($message, array $context = []) + public function warning(string|Stringable $message, array $context = []): void { } /** * Normal but significant events. * - * @param string $message + * @param \Stringable|string $message * @param array $context * @return void */ - public function notice($message, array $context = []) + public function notice(string|Stringable $message, array $context = []): void { } @@ -92,22 +93,22 @@ public function notice($message, array $context = []) * * Example: User logs in, SQL logs. * - * @param string $message + * @param \Stringable|string $message * @param array $context * @return void */ - public function info($message, array $context = []) + public function info(string|Stringable $message, array $context = []): void { } /** * Detailed debug information. * - * @param string $message + * @param \Stringable|string $message * @param array $context * @return void */ - public function debug($message, array $context = []) + public function debug(string|Stringable $message, array $context = []): void { } @@ -115,11 +116,11 @@ public function debug($message, array $context = []) * Logs with an arbitrary level. * * @param mixed $level - * @param string $message + * @param \Stringable|string $message * @param array $context * @return void */ - public function log($level, $message, array $context = []) + public function log($level, string|Stringable $message, array $context = []): void { } } diff --git a/tests/test_app/src/Webservice/StaticWebservice.php b/tests/test_app/src/Webservice/StaticWebservice.php index 2265fa8..95d9d1e 100644 --- a/tests/test_app/src/Webservice/StaticWebservice.php +++ b/tests/test_app/src/Webservice/StaticWebservice.php @@ -11,7 +11,7 @@ class StaticWebservice implements WebserviceInterface { - public function execute(Query $query, array $options = []) + public function execute(Query $query, array $options = []): ResultSet { return new ResultSet([ new Resource([ diff --git a/tests/test_app/src/Webservice/TestWebservice.php b/tests/test_app/src/Webservice/TestWebservice.php index c4e8e59..b27a616 100644 --- a/tests/test_app/src/Webservice/TestWebservice.php +++ b/tests/test_app/src/Webservice/TestWebservice.php @@ -4,16 +4,20 @@ namespace TestApp\Webservice; use Muffin\Webservice\Model\Endpoint; +use Muffin\Webservice\Model\Resource; use Muffin\Webservice\Webservice\Webservice; class TestWebservice extends Webservice { - public function createResource($resourceClass, array $properties = []) + public function createResource($resourceClass, array $properties = []): Resource { return $this->_createResource($resourceClass, $properties); } - public function transformResults(Endpoint $endpoint, array $results) + /** + * @return Resource[] + */ + public function transformResults(Endpoint $endpoint, array $results): array { return $this->_transformResults($endpoint, $results); }