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 @@
+