diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml new file mode 100644 index 0000000..96c4f6d --- /dev/null +++ b/.github/workflows/coding-standards.yml @@ -0,0 +1,55 @@ + +name: "Coding Standards" + +on: + pull_request: + branches: + - "*" + push: + branches: + - "*" + +jobs: + coding-standards: + name: "Coding Standards" + runs-on: ${{ matrix.operating-system }} + strategy: + fail-fast: true + matrix: + php-version: + - "8.3" + - "8.4" + operating-system: [ubuntu-24.04] + composer-versions: + - lowest + - highest + + steps: + - name: "Checkout" + uses: "actions/checkout@v4" + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + coverage: "xdebug" + php-version: "#{{ matrix.php-version }}" + + - name: "Install dependencies with Composer" + uses: "ramsey/composer-install@v3" + with: + dependency-versions: "${{ matrix.composer-versions}}" + - name: "Run PHPCS" + run: | + composer run test-phpcs + + - name: "Run rector" + run: | + composer run test-rector + + - name: "Run phpstan" + run: | + composer run phpstan + + - name: "Run phpunit" + run: | + composer run phpunit diff --git a/.gitignore b/.gitignore index c41fdf3..7136c89 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ vendor/ composer.lock -phpunit.xml \ No newline at end of file +phpunit.xml +.php-cs-fixer.php +.php-cs-fixer.cache +.phpunit.result.cache +tests/coverage +!tests/coverage/.gitkeep +.phpunit.cache \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..46c0ec0 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,24 @@ +in(__DIR__.'/src') + ->in(__DIR__.'/tests') +; + +return (new PhpCsFixer\Config('typesense-bundle')) + ->setRules([ + '@Symfony' => true, + 'array_syntax' => ['syntax' => 'short'], + 'concat_space' => ['spacing' => 'none'], + 'phpdoc_align' => ['align' => 'vertical'], + 'yoda_style' => false, // Disable Yoda conditions for readability + 'no_unused_imports' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'single_line_throw' => false, + ]) + ->setLineEnding(PHP_EOL) + ->setFinder($finder) +; diff --git a/Dockerfile b/Dockerfile index dcb0312..ff6338e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,17 @@ -FROM php:8.2-cli -COPY --from=composer /usr/bin/composer /usr/bin/composer +FROM php:8.3-cli RUN apt-get update && apt-get install -y git unzip && rm -Rf /var/lib/apt/lists/* +COPY --from=composer /usr/bin/composer /usr/bin/composer RUN mkdir -p /.composer/cache/ && chown -R 1000:1000 /.composer/cache/ -USER 1000:1000 \ No newline at end of file + +ENV XDEBUG_MODE=off +COPY --from=ghcr.io/mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ +RUN install-php-extensions xdebug +RUN echo ' \n\ +[xdebug] \n\ +xdebug.enable=1 \n\ +xdebug.idekey=PHPSTORM \n\ +xdebug.client_host=host.docker.internal\n ' >> /usr/local/etc/php/conf.d/xdebug.ini + +USER www-data +USER 1000:1000 diff --git a/composer.json b/composer.json index da8d172..a850774 100644 --- a/composer.json +++ b/composer.json @@ -2,19 +2,31 @@ "name": "biblioteca/typesense-bundle", "description": "This bundle provides integration with Typesense in Symfony", "type": "symfony-bundle", - "minimum-stability": "dev", + "minimum-stability": "stable", "require": { "php": ">=8.2", + "php-http/discovery": "^1.20", + "psr/http-client": "^1.0", + "psr/http-client-implementation": "*", + "psr/log": "^1.1", "symfony/framework-bundle": "^6.4|^7.0", - "symfony/http-client": "*", + "symfony/http-client": "^7.2", "symfony/http-kernel": "^6.4|^7.0", "typesense/typesense-php": "^4.9" }, "require-dev": { + "doctrine/doctrine-bundle": "^2.0", + "doctrine/orm": "^3.0", "friendsofphp/php-cs-fixer": "dev-master", "phpstan/phpstan": "^2.0", + "phpstan/phpstan-symfony": "^2.0", "phpunit/phpunit": "^11.5", - "rector/rector": "^2.0" + "rector/rector": "^2.0", + "symfony/phpunit-bridge": "^7.0", + "symfony/yaml": "^7.0" + }, + "conflict": { + "php-http/httplug": "<1.5" }, "config": { "sort-packages": true, @@ -29,6 +41,7 @@ }, "autoload-dev": { "psr-4": { + "Biblioteca\\TypesenseBundle\\Tests\\": "tests" } }, "scripts": { @@ -38,31 +51,35 @@ "phpstan": [ "Composer\\Config::disableProcessTimeout", - "env XDEBUG_MODE=off ./vendor/bin/phpstan analyse --memory-limit=-1" + "./vendor/bin/phpstan analyse --memory-limit=-1" + ], + "test-phpcs": [ + "Composer\\Config::disableProcessTimeout", + "./vendor/bin/php-cs-fixer fix --dry-run --verbose -vv" ], "phpcs": [ "Composer\\Config::disableProcessTimeout", - "env XDEBUG_MODE=off ./vendor/bin/php-cs-fixer fix --dry-run --verbose -vv" + "./vendor/bin/php-cs-fixer fi --verbose -vv" ], "test-rector": [ "Composer\\Config::disableProcessTimeout", - "env XDEBUG_MODE=off ./vendor/bin/rector --dry-run" + "./vendor/bin/rector --dry-run" ], "rector": [ "Composer\\Config::disableProcessTimeout", - "env XDEBUG_MODE=off ./vendor/bin/rector" + "./vendor/bin/rector" ], - "test-phpunit": [ + "phpunit": [ "Composer\\Config::disableProcessTimeout", - "env XDEBUG_MODE=off php -d memory_limit=-1 ./vendor/bin/phpunit --colors=always" + "php -d memory_limit=-1 ./vendor/bin/phpunit --colors=always" ], - "test-phpunit-coverage": [ + "phpunit-coverage": [ "Composer\\Config::disableProcessTimeout", "env XDEBUG_MODE=coverage php -d memory_limit=-1 ./vendor/bin/phpunit --colors=always --coverage-html=tests/coverage" ], - "test-phpunit-xdebug": [ + "phpunit-xdebug": [ "Composer\\Config::disableProcessTimeout", - "env XDEBUG_MODE=debug XDEBUG_TRIGGER=1 php -d memory_limit=-1 ./vendor/bin/phpunit --colors=always" + "env XDEBUG_MODE=debug,coverage XDEBUG_TRIGGER=1 php -d memory_limit=-1 ./vendor/bin/phpunit --colors=always" ], "lint": [ "@rector", @@ -71,9 +88,9 @@ ], "test": [ "@test-phpcs", - "@test-phpstan", + "@phpstan", "@test-rector", - "@test-phpunit" + "@phpunit" ] } } diff --git a/docker-compose.yml b/docker-compose.yml index 819a4fe..7305b13 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,10 @@ services: - .:/var/www/html stdin_open: true tty: true + environment: + - PHP_IDE_CONFIG=serverName=typesensebundle + extra_hosts: + - host.docker.internal:host-gateway networks: - default # typesense: diff --git a/justfile b/justfile index 1ab825a..3ada674 100644 --- a/justfile +++ b/justfile @@ -1,16 +1,39 @@ -set shell := ["docker", "compose", "run", "-it", "--user=1000", "--rm", "php", "/usr/bin/composer"] -composer *args: - {{args}} +set shell := ["docker", "compose", "run", "--entrypoint", "/bin/sh", "-it", "--user=1000", "--rm", "php", "-c"] +composer *args="": + /usr/bin/composer {{args}} + +sh *args="": + sh {{args}} + +php *args="": + php {{args}} install: - install + composer install tests: - test -update: - update + composer run test + +update *args="": + composer update {{args}} + lint: - lint + composer run lint rector: - rector \ No newline at end of file + rector + +test-phpcs: + composer run test-phpcs + +phpcs: + composer run phpcs + +phpunit *args="": + env XDEBUG_MODE=coverage composer run phpunit -- {{args}} + +phpunit-xdebug *args="": + composer phpunit-xdebug -- {{args}} + +phpstan *args="": + composer phpstan -- {{args}} \ No newline at end of file diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..98caf11 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,17 @@ +parameters: + tipsOfTheDay: false + level: max + paths: + - src/ + - tests/ + ignoreErrors: + - '#(.*)no value type specified in iterable type array#' + - + identifier: 'method.notFound' + path: src/BibliotecaTypesenseBundle.php + - + identifier: 'method.nonObject' + path: src/BibliotecaTypesenseBundle.php + - + identifier: 'argument.type' + path: src/BibliotecaTypesenseBundle.php \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..23ec32b --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,35 @@ + + + + + + tests + + + + + + + + + + + + + + + + + + + + + vendor + + + src + + + diff --git a/src/BibliotecaTypesenseBundle.php b/src/BibliotecaTypesenseBundle.php index 4f03a88..7d9cffe 100644 --- a/src/BibliotecaTypesenseBundle.php +++ b/src/BibliotecaTypesenseBundle.php @@ -63,7 +63,9 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $builder->registerForAutoconfiguration(MapperInterface::class) ->addTag(MapperInterface::TAG_NAME); - foreach ($config['typesense'] as $key => $value) { + /** @var iterable $typesenseConfig */ + $typesenseConfig = $config['typesense']; + foreach ($typesenseConfig as $key => $value) { $container->parameters()->set('biblioteca_typesense.config.'.$key, $value); } @@ -74,6 +76,9 @@ public function loadExtension(array $config, ContainerConfigurator $container, C public function loadCollection(array $collections, ContainerConfigurator $container, ContainerBuilder $builder): void { + /** + * @var array{entity: string} $collection + */ foreach ($collections as $name => $collection) { $id = 'biblioteca_typesense.collection.'.$name; $container->services() diff --git a/src/Client/ClientAdapter.php b/src/Client/ClientAdapter.php index a299705..3961f00 100644 --- a/src/Client/ClientAdapter.php +++ b/src/Client/ClientAdapter.php @@ -5,6 +5,7 @@ use Typesense\Aliases; use Typesense\Analytics; use Typesense\Client; +use Typesense\Collection; use Typesense\Collections; use Typesense\Debug; use Typesense\Health; @@ -21,7 +22,7 @@ public function __construct( ) { } - public function __call($name, $arguments) + public function __call(mixed $name, mixed $arguments): mixed { return $this->client->$name(...$arguments); } @@ -75,4 +76,9 @@ public function getAnalytics(): Analytics { return $this->client->getAnalytics(); } + + public function getCollection(string $name): Collection + { + return $this->client->getCollections()[$name]; + } } diff --git a/src/Client/ClientFactory.php b/src/Client/ClientFactory.php index 9951d77..b6a3d4c 100644 --- a/src/Client/ClientFactory.php +++ b/src/Client/ClientFactory.php @@ -2,16 +2,23 @@ namespace Biblioteca\TypesenseBundle\Client; +use Http\Discovery\Psr18ClientDiscovery; +use Psr\Http\Client\ClientInterface as HttpClient; use Typesense\Client; use Typesense\Exceptions\ConfigError; -class ClientFactory +readonly class ClientFactory { + /** + * @param array $defaultConfig + */ public function __construct( - private readonly string $uri, + private string $uri, #[\SensitiveParameter] - private readonly string $apiKey, - private readonly int $connectionTimeoutSeconds = 5, + private string $apiKey, + private ?HttpClient $client, + private int $connectionTimeoutSeconds = 5, + private array $defaultConfig = [], ) { } @@ -26,11 +33,11 @@ public function __invoke(): ClientInterface private function getConfiguration(): array { $urlParsed = parse_url($this->uri); - if ($urlParsed === false) { - throw new \InvalidArgumentException('Invalid URI'); + if ($urlParsed === false || empty($urlParsed['host']) || empty($urlParsed['port']) || empty($urlParsed['scheme'])) { + throw new \InvalidArgumentException('Invalid URI .'.$this->uri); } - return [ + $config = [ 'nodes' => [ [ 'host' => $urlParsed['host'], @@ -38,8 +45,16 @@ private function getConfiguration(): array 'protocol' => $urlParsed['scheme'], ], ], + 'client' => $this->getClient(), 'api_key' => $this->apiKey, 'connection_timeout_seconds' => $this->connectionTimeoutSeconds, ]; + + return array_merge($this->defaultConfig, $config); + } + + public function getClient(): HttpClient + { + return $this->client ?? (new Psr18ClientDiscovery())->find(); } } diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php index bc6215d..c10c1ee 100644 --- a/src/Client/ClientInterface.php +++ b/src/Client/ClientInterface.php @@ -4,6 +4,7 @@ use Typesense\Aliases; use Typesense\Analytics; +use Typesense\Collection; use Typesense\Collections; use Typesense\Debug; use Typesense\Health; @@ -15,6 +16,8 @@ interface ClientInterface { + public function getCollection(string $name): Collection; + public function getCollections(): Collections; public function getAliases(): Aliases; diff --git a/src/CollectionName/AliasName.php b/src/CollectionName/AliasName.php index cd6f76e..d99928f 100644 --- a/src/CollectionName/AliasName.php +++ b/src/CollectionName/AliasName.php @@ -3,6 +3,9 @@ namespace Biblioteca\TypesenseBundle\CollectionName; use Biblioteca\TypesenseBundle\Client\ClientInterface; +use Biblioteca\TypesenseBundle\Exception\AliasException; +use Http\Client\Exception; +use Typesense\Exceptions\TypesenseClientError; class AliasName implements NameInterface { @@ -28,18 +31,41 @@ public function isAliasEnabled(): bool return true; } + /** + * @throws AliasException + */ public function switch(string $shortName, string $longName): void { if (!$this->isAliasEnabled()) { return; } - // If alias was previously a collection, we delete it (to make sure we can create the alias) - if ($this->client->getCollections()->__get($shortName)->exists()) { - $this->client->getCollections()->__get($shortName)->delete(); + try { + // If alias was previously a collection, we delete it (to make sure we can create the alias) + $collection = $this->client->getCollection($shortName); + + if ($this->collectionExists($shortName)) { + $collection->delete(); + } + + // Point the alias to the new collection (Note that the old collection is deleted automatically!) + $this->client->getAliases()->upsert($shortName, ['collection_name' => $longName]); + } catch (TypesenseClientError|Exception $e) { + throw new AliasException($e->getMessage(), $e->getCode(), $e); } + } - // Point the alias to the new collection (Note that the old collection is deleted automatically!) - $this->client->getAliases()->upsert($shortName, ['collection_name' => $longName]); + /** + * Collection->exists() method is not available in typesense-php 4.x. + */ + private function collectionExists(string $name): bool + { + try { + $this->client->getCollection($name)->retrieve(); + + return true; + } catch (TypesenseClientError|Exception) { + return false; + } } } diff --git a/src/Exception/AliasException.php b/src/Exception/AliasException.php new file mode 100644 index 0000000..3d85889 --- /dev/null +++ b/src/Exception/AliasException.php @@ -0,0 +1,7 @@ + $repository + */ public function __construct( - private readonly ObjectRepository $repository, + private EntityRepository $repository, ) { } @@ -46,6 +49,7 @@ public function getDataCount(): ?int /** * @param object&T $data + * * @return array */ abstract public function transform(object $data): array; diff --git a/src/Mapper/Fields/FieldMappingInterface.php b/src/Mapper/Fields/FieldMappingInterface.php index 1f350d0..465d023 100644 --- a/src/Mapper/Fields/FieldMappingInterface.php +++ b/src/Mapper/Fields/FieldMappingInterface.php @@ -7,14 +7,14 @@ interface FieldMappingInterface { /** - * Field options and value + * Field options and value. + * * @return array */ public function toArray(): array; /** * @see DataTypeEnum - * @return string */ public function getType(): string; diff --git a/src/Mapper/Locator/MapperLocator.php b/src/Mapper/Locator/MapperLocator.php index ddccbdf..e168c1e 100644 --- a/src/Mapper/Locator/MapperLocator.php +++ b/src/Mapper/Locator/MapperLocator.php @@ -6,9 +6,12 @@ use Psr\Container\ContainerExceptionInterface; use Symfony\Component\DependencyInjection\ServiceLocator; -class MapperLocator implements MapperLocatorInterface +readonly class MapperLocator implements MapperLocatorInterface { - public function __construct(private readonly ServiceLocator $mappers) + /** + * @param ServiceLocator $mappers + */ + public function __construct(private ServiceLocator $mappers) { } @@ -40,7 +43,11 @@ public function getMappers(): \Generator $mappers = []; foreach (array_keys($this->mappers->getProvidedServices()) as $name) { try { - $mappers[$name] = $this->mappers->get($name); + $service = $this->mappers->get($name); + if (!$service instanceof MapperInterface) { + throw new \InvalidArgumentException(sprintf('The mapper "%s" must implement "%s".', $name, MapperInterface::class)); + } + $mappers[$name] = $service; } catch (ContainerExceptionInterface) { continue; } diff --git a/src/Mapper/MapperInterface.php b/src/Mapper/MapperInterface.php index 1bb6303..85e4a21 100644 --- a/src/Mapper/MapperInterface.php +++ b/src/Mapper/MapperInterface.php @@ -13,14 +13,14 @@ public static function getName(): string; public function getMapping(): MappingInterface; /** - * Data to index, the key is the field name + * Data to index, the key is the field name. + * * @return \Generator> */ public function getData(): \Generator; /** * How many data to index. If null, the progression is unknown. - * @return int|null */ public function getDataCount(): ?int; } diff --git a/src/Mapper/Metadata/MetadataMapping.php b/src/Mapper/Metadata/MetadataMapping.php index 168526f..ee30419 100644 --- a/src/Mapper/Metadata/MetadataMapping.php +++ b/src/Mapper/Metadata/MetadataMapping.php @@ -6,9 +6,13 @@ /** * @implements \ArrayAccess + * @implements \IteratorAggregate */ class MetadataMapping implements MetadataMappingInterface, \ArrayAccess, \IteratorAggregate { + /** + * @use ArrayAccessTrait + */ use ArrayAccessTrait; /** diff --git a/src/Mapper/Metadata/MetadataMappingInterface.php b/src/Mapper/Metadata/MetadataMappingInterface.php index 950ce4b..840b5f6 100644 --- a/src/Mapper/Metadata/MetadataMappingInterface.php +++ b/src/Mapper/Metadata/MetadataMappingInterface.php @@ -5,7 +5,8 @@ interface MetadataMappingInterface { /** - * Metadata options and value + * Metadata options and value. + * * @return array */ public function toArray(): array; diff --git a/src/Populate/BatchGenerator.php b/src/Populate/BatchGenerator.php index 9ee6160..e4e307d 100644 --- a/src/Populate/BatchGenerator.php +++ b/src/Populate/BatchGenerator.php @@ -2,6 +2,9 @@ namespace Biblioteca\TypesenseBundle\Populate; +/** + * @template T + */ class BatchGenerator { private readonly int $batchSize; @@ -9,8 +12,9 @@ class BatchGenerator /** * Constructor to initialize the iterable and batch size. * - * @param iterable $iterable the data source to process - * @param int $batchSize the number of elements in each batch + * @param iterable $iterable the data source to process + * @param int $batchSize the number of elements in each batch + * * @throws \InvalidArgumentException if batch size is not greater than 0 */ public function __construct(private readonly iterable $iterable, int $batchSize) @@ -24,7 +28,7 @@ public function __construct(private readonly iterable $iterable, int $batchSize) /** * Generate batches of elements from the iterable. * - * @return \Generator yields an array of elements in each batch + * @return \Generator> yields an array of elements in each batch */ public function generate(): \Generator { diff --git a/src/Populate/PopulateService.php b/src/Populate/PopulateService.php index 07ba6c2..3ea4ef7 100644 --- a/src/Populate/PopulateService.php +++ b/src/Populate/PopulateService.php @@ -19,7 +19,7 @@ public function __construct( public function deleteCollection(string $name): void { - $this->client->getCollections()->__get($name)->delete(); + $this->client->getCollection($name)->delete(); // @phpstan-ignore method.nonObject } public function createCollection(string $collectionName, MapperInterface $mapper): Collection @@ -29,14 +29,14 @@ public function createCollection(string $collectionName, MapperInterface $mapper $payload = array_filter([ 'name' => $collectionName, 'fields' => array_map(fn (FieldMappingInterface $mapping): array => $mapping->toArray(), $mapping->getFields()), - 'metadata' => $mapping->getMetadata() ? $mapping->getMetadata()?->toArray() : null, + 'metadata' => $mapping->getMetadata()?->toArray(), ...$mapping->getCollectionOptions()?->toArray() ?? [], ], fn ($value): bool => !is_null($value)); try { $this->client->getCollections()->create($payload); - return $this->client->getCollections()->__get($collectionName); + return $this->client->getCollection($collectionName); } catch (Exception|TypesenseClientError $e) { throw new \RuntimeException('Unable to create collection', 0, $e); } @@ -44,7 +44,7 @@ public function createCollection(string $collectionName, MapperInterface $mapper public function fillCollection(string $name, MapperInterface $mapper): \Generator { - $collection = $this->client->getCollections()->offsetGet($name); + $collection = $this->client->getCollection($name); $data = $mapper->getData(); foreach ((new BatchGenerator($data, $this->batchSize))->generate() as $items) { $collection->documents->import($items); diff --git a/src/Query/SearchQuery.php b/src/Query/SearchQuery.php index 568df93..a3f5211 100644 --- a/src/Query/SearchQuery.php +++ b/src/Query/SearchQuery.php @@ -7,20 +7,14 @@ class SearchQuery implements SearchQueryInterface { private readonly ?string $infix; - /** - * @var bool[]|null - */ - private readonly ?array $prefix; + private readonly ?string $prefix; - /** - * @var string[]|null - */ - private readonly ?array $stopwords; + private readonly ?string $stopwords; /** * @param string|InfixEnum[]|null $infix - * @param bool|bool[]|null $prefix - * @param string[]|null $stopwords + * @param bool|bool[]|null $prefix + * @param string[]|null $stopwords */ public function __construct( private readonly string $q, @@ -56,8 +50,13 @@ public function __construct( private readonly ?VoiceQueryInterface $voiceQuery = null, ) { $this->infix = $this->convertArray($infix, fn ($infix) => $infix instanceof InfixEnum ? $infix->value : $infix, InfixEnum::class); - $this->prefix = $prefix === [] || $prefix - === null ? null : implode(',', array_map(fn (bool $value): string => $value ? 'true' : 'false', $prefix)); + + if (is_bool($prefix)) { + $prefix = (array) $prefix; + } + + $this->prefix = $prefix === [] || $prefix === null ? null : + implode(',', array_map(fn (bool $value): string => $value ? 'true' : 'false', $prefix)); $this->stopwords = $stopwords === null || $stopwords === [] ? null : implode(',', $stopwords); // Check incompatible combinations @@ -69,7 +68,18 @@ public function __construct( private function convertArray(mixed $values, callable $convert, ?string $className = null): ?string { if (!is_array($values)) { - return $values === null ? null : $convert($values); + if ($values === null) { + return null; + } + $values = $convert($values); + if ($values === null) { + return null; + } + if (!is_scalar($values)) { + throw new \InvalidArgumentException('Expected scalar value'); + } + + return (string) $values; } foreach ($values as $value) { if ($className !== null && !$value instanceof $className) { diff --git a/src/Search/Hydrate/HydrateRepositoryInterface.php b/src/Search/Hydrate/HydrateRepositoryInterface.php index 38ccf36..a5cfe38 100644 --- a/src/Search/Hydrate/HydrateRepositoryInterface.php +++ b/src/Search/Hydrate/HydrateRepositoryInterface.php @@ -4,7 +4,15 @@ use Doctrine\Common\Collections\Collection; +/** + * @template T of object + */ interface HydrateRepositoryInterface { + /** + * @param int[] $ids + * + * @return Collection + */ public function findByIds(array $ids): Collection; } diff --git a/src/Search/Hydrate/HydrateSearchResult.php b/src/Search/Hydrate/HydrateSearchResult.php index ae88848..7e29ba3 100644 --- a/src/Search/Hydrate/HydrateSearchResult.php +++ b/src/Search/Hydrate/HydrateSearchResult.php @@ -6,6 +6,11 @@ use Biblioteca\TypesenseBundle\Search\Results\SearchResultsHydrated; use Doctrine\ORM\EntityManagerInterface; +/** + * @template T of object + * + * @implements HydrateSearchResultInterface + */ class HydrateSearchResult implements HydrateSearchResultInterface { private ?string $primaryKeyOverride = null; @@ -15,6 +20,10 @@ public function __construct(private readonly EntityManagerInterface $entityManag } /** + * @param class-string $class + * + * @return SearchResultsHydrated + * * @throws \Exception */ public function hydrate(string $class, SearchResults $results): SearchResultsHydrated @@ -26,16 +35,19 @@ public function hydrate(string $class, SearchResults $results): SearchResultsHyd $primaryKeyName = ($this->primaryKeyOverride ?? $primaryKey?->getName()) ?? 'id'; $hits = $results['hits'] ?? []; - $ids = array_map(fn ($result): mixed => $result['document'][$primaryKeyName] ?? null, $hits); + $ids = array_map(fn ($result): mixed => (int) $result['document'][$primaryKeyName] ?? null, $hits); // @phpstan-ignore-line $ids = array_filter($ids); if ($ids === []) { - return new SearchResultsHydrated($results, []); + return new SearchResultsHydrated($results, []); // @phpstan-ignore return.type } $repository = $this->entityManager->getRepository($class); if ($repository instanceof HydrateRepositoryInterface) { - return $repository->findByIds($ids); + /** @var array $collectionData */ + $collectionData = $repository->findByIds($ids)->toArray(); + + return new SearchResultsHydrated($results, $collectionData); // @phpstan-ignore return.type } // Build a basic query to fetch the entities by their primary key @@ -44,12 +56,16 @@ public function hydrate(string $class, SearchResults $results): SearchResultsHyd ->indexBy('e', 'e.'.$primaryKeyName) ->setParameter('ids', $ids) ->getQuery(); + /** @var array $hydratedResults */ $hydratedResults = (array) $query->getResult(); // TODO Handle pagination ? - return new SearchResultsHydrated($results, $hydratedResults); + return new SearchResultsHydrated($results, $hydratedResults); // @phpstan-ignore return.type } + /** + * @return HydrateSearchResult + */ public function setPrimaryKeyOverride(?string $primaryKeyOverride): self { $this->primaryKeyOverride = $primaryKeyOverride; diff --git a/src/Search/Hydrate/HydrateSearchResultInterface.php b/src/Search/Hydrate/HydrateSearchResultInterface.php index d0878ef..fe287bf 100644 --- a/src/Search/Hydrate/HydrateSearchResultInterface.php +++ b/src/Search/Hydrate/HydrateSearchResultInterface.php @@ -12,6 +12,7 @@ interface HydrateSearchResultInterface { /** * @param class-string $class + * * @return SearchResultsHydrated */ public function hydrate(string $class, SearchResults $results): SearchResultsHydrated; diff --git a/src/Search/Results/SearchResults.php b/src/Search/Results/SearchResults.php index 70e839b..869267b 100644 --- a/src/Search/Results/SearchResults.php +++ b/src/Search/Results/SearchResults.php @@ -9,9 +9,13 @@ /** * @implements \ArrayAccess + * @implements \IteratorAggregate */ class SearchResults implements \ArrayAccess, \IteratorAggregate, \Countable { + /** + * @use ArrayAccessTrait + */ use ArrayAccessTrait; use SearchFacetTrait; use SearchCountTrait; @@ -26,10 +30,10 @@ public function toArray(): array } /** - * @return \Traversable + * @return \Traversable */ public function getIterator(): \Traversable { - return new \ArrayIterator(array_map(fn ($hits): mixed => $hits['document'], $this->data['hits'] ?? [])); + return new \ArrayIterator(array_map(fn ($hits): mixed => $hits['document'], $this->data['hits'] ?? [])); // @phpstan-ignore-line } } diff --git a/src/Search/Results/SearchResultsHydrated.php b/src/Search/Results/SearchResultsHydrated.php index 72c5e4a..0dc2137 100644 --- a/src/Search/Results/SearchResultsHydrated.php +++ b/src/Search/Results/SearchResultsHydrated.php @@ -9,29 +9,38 @@ /** * @template T + * * @implements \ArrayAccess + * @implements \IteratorAggregate */ class SearchResultsHydrated implements \IteratorAggregate, \Countable, \ArrayAccess { + /** + * @use ArrayAccessTrait + */ use ArrayAccessTrait; use FoundCountTrait; use SearchCountTrait; use SearchFacetTrait; /** - * @param $hydratedResults iterable + * @param array $hydratedResults + * @param SearchResults $results + * * @throws \Exception */ - public function __construct(SearchResults $results, iterable $hydratedResults = []) + public function __construct(SearchResults $results, array $hydratedResults = []) { $this->data = $results->toArray(); $this->setHydratedResults($hydratedResults); } /** - * @param iterable $data + * @param array $data + * + * @return SearchResultsHydrated */ - public function setHydratedResults(iterable $data): self + public function setHydratedResults(array $data): self { $this->data['hydrated'] = $data; @@ -39,10 +48,14 @@ public function setHydratedResults(iterable $data): self } /** - * @return \Traversable + * @return \Traversable */ public function getIterator(): \Traversable { - return new \ArrayIterator($this->data['hydrated'] ?? []); + if (!$this->offsetExists('hydrated')) { + return new \ArrayIterator([]); + } + + return new \ArrayIterator((array) $this->data['hydrated']); // @phpstan-ignore return.type } } diff --git a/src/Search/Search.php b/src/Search/Search.php index 1dcf2bc..37c9827 100644 --- a/src/Search/Search.php +++ b/src/Search/Search.php @@ -3,6 +3,7 @@ namespace Biblioteca\TypesenseBundle\Search; use Biblioteca\TypesenseBundle\Client\ClientInterface; +use Biblioteca\TypesenseBundle\Exception\SearchException; use Biblioteca\TypesenseBundle\Query\SearchQuery; use Biblioteca\TypesenseBundle\Search\Results\SearchResults; use Http\Client\Exception; @@ -15,12 +16,15 @@ public function __construct(private readonly ClientInterface $client) } /** - * @throws Exception - * @throws TypesenseClientError + * @throws SearchException */ public function search(string $collectionName, SearchQuery $query): SearchResults { - return new SearchResults($this->client->getCollections()->__get($collectionName) - ->documents->search($query->toArray())); + try { + return new SearchResults($this->client->getCollection($collectionName) // @phpstan-ignore-line + ->documents->search($query->toArray())); + } catch (TypesenseClientError|Exception $e) { + throw new SearchException($e->getMessage(), $e->getCode(), $e); + } } } diff --git a/src/Search/SearchCollection.php b/src/Search/SearchCollection.php index cb312c8..0cd91b4 100644 --- a/src/Search/SearchCollection.php +++ b/src/Search/SearchCollection.php @@ -7,8 +7,17 @@ use Biblioteca\TypesenseBundle\Search\Results\SearchResults; use Biblioteca\TypesenseBundle\Search\Results\SearchResultsHydrated; +/** + * @template T + * + * @implements SearchCollectionInterface + */ class SearchCollection implements SearchCollectionInterface { + /** + * @param class-string $entityClass + * @param HydrateSearchResultInterface $hydrateSearchResult + */ public function __construct( private readonly string $collectionName, private readonly string $entityClass, diff --git a/src/Search/SearchCollectionInterface.php b/src/Search/SearchCollectionInterface.php index ea086a8..e33ba01 100644 --- a/src/Search/SearchCollectionInterface.php +++ b/src/Search/SearchCollectionInterface.php @@ -2,6 +2,7 @@ namespace Biblioteca\TypesenseBundle\Search; +use Biblioteca\TypesenseBundle\Exception\SearchException; use Biblioteca\TypesenseBundle\Query\SearchQuery; use Biblioteca\TypesenseBundle\Search\Results\SearchResults; use Biblioteca\TypesenseBundle\Search\Results\SearchResultsHydrated; @@ -13,6 +14,8 @@ interface SearchCollectionInterface { /** * @return SearchResultsHydrated + * + * @throws SearchException */ public function search(SearchQuery $query): SearchResultsHydrated; diff --git a/src/Search/SearchInterface.php b/src/Search/SearchInterface.php index b04f09a..181d6fd 100644 --- a/src/Search/SearchInterface.php +++ b/src/Search/SearchInterface.php @@ -2,16 +2,14 @@ namespace Biblioteca\TypesenseBundle\Search; +use Biblioteca\TypesenseBundle\Exception\SearchException; use Biblioteca\TypesenseBundle\Query\SearchQuery; use Biblioteca\TypesenseBundle\Search\Results\SearchResults; -use Http\Client\Exception; -use Typesense\Exceptions\TypesenseClientError; interface SearchInterface { /** - * @throws Exception - * @throws TypesenseClientError + * @throws SearchException */ public function search(string $collectionName, SearchQuery $query): SearchResults; } diff --git a/src/Search/Traits/FoundCountTrait.php b/src/Search/Traits/FoundCountTrait.php index eeadf39..b818869 100644 --- a/src/Search/Traits/FoundCountTrait.php +++ b/src/Search/Traits/FoundCountTrait.php @@ -6,6 +6,10 @@ trait FoundCountTrait { public function found(): int { - return intval($this->data['found'] ?? 0); + if (!$this->offsetExists('found') || !is_scalar($this->data['found'])) { + return 0; + } + + return intval($this->data['found']); } } diff --git a/src/Search/Traits/SearchCountTrait.php b/src/Search/Traits/SearchCountTrait.php index 71fa74d..bc81826 100644 --- a/src/Search/Traits/SearchCountTrait.php +++ b/src/Search/Traits/SearchCountTrait.php @@ -6,6 +6,10 @@ trait SearchCountTrait { public function count(): int { - return count($this->data['hits'] ?? []); + if (!$this->offsetExists('hits')) { + return 0; + } + + return count((array) $this->data['hits']); } } diff --git a/src/Search/Traits/SearchFacetTrait.php b/src/Search/Traits/SearchFacetTrait.php index f641635..d3ca45b 100644 --- a/src/Search/Traits/SearchFacetTrait.php +++ b/src/Search/Traits/SearchFacetTrait.php @@ -13,6 +13,10 @@ trait SearchFacetTrait */ public function getFacetCounts(): array { - return $this->data['facet_counts']; + if (!$this->offsetExists('facet_counts')) { + return []; + } + + return (array) $this->data['facet_counts']; // @phpstan-ignore return.type } } diff --git a/src/Utils/ArrayAccessTrait.php b/src/Utils/ArrayAccessTrait.php index 41323e7..f83c631 100644 --- a/src/Utils/ArrayAccessTrait.php +++ b/src/Utils/ArrayAccessTrait.php @@ -5,6 +5,7 @@ /** * @template TKey * @template TValue + * * @implements \ArrayAccess */ trait ArrayAccessTrait @@ -23,7 +24,6 @@ public function getIterator(): \Traversable /** * @param TKey $offset - * @return bool */ public function offsetExists(mixed $offset): bool { @@ -32,6 +32,7 @@ public function offsetExists(mixed $offset): bool /** * @param TKey $offset + * * @return TValue|null */ public function offsetGet(mixed $offset): mixed @@ -41,7 +42,6 @@ public function offsetGet(mixed $offset): mixed /** * @param TKey $offset - * @return TValue|null $value */ public function offsetSet(mixed $offset, mixed $value): void { diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php new file mode 100644 index 0000000..5ef960f --- /dev/null +++ b/tests/ContainerTest.php @@ -0,0 +1,81 @@ + + */ + protected static function getKernelClass(): string + { + return TestKernel::class; + } + + /** + * @param array{'bundles'?: class-string, 'configs'?: array, 'environment'?: string, 'debug'?:bool} $options + */ + protected static function createKernel(array $options = []): KernelInterface + { + static::$class ??= static::getKernelClass(); + + if (false === in_array(self::CONFIG_KEY, array_keys($options['configs'] ?? []))) { + $options['configs'][self::CONFIG_KEY] = __DIR__.'/config/packages/biblioteca_typesense.yaml'; + } + $env = $options['environment'] ?? $_ENV['APP_ENV'] ?? $_SERVER['APP_ENV'] ?? 'test'; + $debug = $options['debug'] ?? $_ENV['APP_DEBUG'] ?? $_SERVER['APP_DEBUG'] ?? true; + + $kernel = new static::$class($env, $debug, $options); + if (!$kernel instanceof KernelInterface) { + throw new \InvalidArgumentException('Kernel must be an instance of '.KernelInterface::class); + } + + return $kernel; + } + + public function testMapperLocatorExists(): void + { + $kernel = self::bootKernel(); + $container = $kernel->getContainer(); + + $this->assertContainerHas($container, MapperLocator::class); + } + + public function testClientFactory(): void + { + $kernel = self::bootKernel(); + $container = $kernel->getContainer(); + + $this->assertContainerHas($container, ServiceWithClient::class); + + $service = $container->get(ServiceWithClient::class); + $this->assertInstanceOf(ServiceWithClient::class, $service); + } + + public function testClientFactoryInvalidUrl(): void + { + $this->expectException(\InvalidArgumentException::class); + $kernel = self::bootKernel([ + 'configs' => [self::CONFIG_KEY => __DIR__.'/config/packages/biblioteca_typesense_wrong_url.yaml'], + ]); + $container = $kernel->getContainer(); + + $this->assertContainerHas($container, ServiceWithClient::class); + + $service = $container->get(ServiceWithClient::class); + $this->assertInstanceOf(ServiceWithClient::class, $service); + } + + protected function assertContainerHas(ContainerInterface $container, string $serviceId): void + { + $this->assertTrue($container->has($serviceId), sprintf('The service "%s" should be in the container.', $serviceId)); + } +} diff --git a/tests/ServiceWithClient.php b/tests/ServiceWithClient.php new file mode 100644 index 0000000..c79b0ac --- /dev/null +++ b/tests/ServiceWithClient.php @@ -0,0 +1,19 @@ +debug( + 'ServiceWithClient::__construct', + ['client' => $client] + ); + } +} diff --git a/tests/TestKernel.php b/tests/TestKernel.php new file mode 100644 index 0000000..d57b196 --- /dev/null +++ b/tests/TestKernel.php @@ -0,0 +1,90 @@ +settings['bundles'] ?? []); + + foreach ($bundles as $bundleClass) { + $instance = new $bundleClass(); + + if (!$instance instanceof BundleInterface) { + throw new \InvalidArgumentException(sprintf('Bundle %s must be an instance of %s', get_class($instance), BundleInterface::class)); + } + yield $instance; + } + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $this->settings['configs'][] = __DIR__.'/config/packages/doctrine.yaml'; + $this->settings['configs'][] = __DIR__.'/config/services.yaml'; + + foreach ($this->settings['configs'] as $config) { + $loader->load($config); + } + } + + public function getCacheDir(): string + { + return realpath(sys_get_temp_dir()).'/TypesenseTests/cache'; + } + + public function getLogDir(): string + { + return realpath(sys_get_temp_dir()).'/TypesenseTests/log'; + } + + public function getProjectDir(): string + { + return __DIR__.'/kernel'; + } + + public function shutdown(): void + { + parent::shutdown(); + + $cacheDirectory = $this->getCacheDir(); + $logDirectory = $this->getLogDir(); + + $filesystem = new Filesystem(); + + if ($filesystem->exists($cacheDirectory)) { + $filesystem->remove($cacheDirectory); + } + + if ($filesystem->exists($logDirectory)) { + $filesystem->remove($logDirectory); + } + } +} diff --git a/tests/baseline-ignore b/tests/baseline-ignore new file mode 100644 index 0000000..e69de29 diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..789d75a --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,3 @@ +