3
0

Compare commits

..

53 Commits

Author SHA1 Message Date
brabli 90e23eb13e Update search service 2026-05-07 17:30:00 +01:00
brabli df9b7f9bf1 Simplify repo 2026-05-07 17:29:54 +01:00
brabli 38546292b6 Add content to readme 2026-05-07 17:29:31 +01:00
brabli 856e434e7c Use new name 2026-05-07 17:11:08 +01:00
brabli 45ed9329da Add types, add docblocks, minor tweaks 2026-05-07 17:10:54 +01:00
brabli 3a0cb87d37 Re-add the getId method to the interface 2026-05-07 16:50:08 +01:00
brabli 01a8dd9834 Add missing return types, formatting 2026-05-07 16:42:31 +01:00
brabli 1809e5a64e Update subscriber to a listener as that's technically what it is 2026-05-07 16:42:06 +01:00
brabli 9d5340327b Update interface with docblocks and a new namespace 2026-05-07 16:33:40 +01:00
brabli 0872868798 Formatting 2026-05-07 16:09:58 +01:00
brabli efc9b17136 Fix outdated code 2026-05-05 11:00:43 +01:00
brabli d3de91ea0b Update deps 2026-05-05 11:00:29 +01:00
brabli f9700e1959 Add general composer command 2026-05-05 10:41:53 +01:00
brabli 92bd22ad99 Update container php ver 2026-05-05 10:41:46 +01:00
brabli 5548df12bd Replace makefile with justfile 2026-05-05 10:38:28 +01:00
mf 6e2c241349 Adding formatting 2023-01-09 16:19:16 +00:00
mf 802413f754 Fixing deprecation 2023-01-09 16:07:13 +00:00
mf e56ea29018 Adding batching to index command for performance 2023-01-09 16:06:37 +00:00
mf 3faec6370c Merge tag '0.0.18' into develop
Trying to load custom Doctrine config
2022-07-29 11:27:46 +01:00
mf 361cd7baf8 Merge branch 'release/0.0.18' 2022-07-29 11:27:31 +01:00
mf 6111bd07e5 Updating Doctrine settings 2022-07-29 11:27:21 +01:00
mf 76ab724d1a Merge branch 'develop' of ssh://git.pcmdev.co.uk:2222/pcm-libraries/pcm-search-bundle into develop 2022-07-29 11:18:59 +01:00
mf d7a7d0b6e8 Merge tag '0.0.17' into develop
- Adding loader for doctrine.yaml to bring in MATCH AGAINST function
2022-07-29 11:18:49 +01:00
mf 3f57c78d01 Merge branch 'release/0.0.17' 2022-07-29 11:18:35 +01:00
mf e88da82267 Added loader for doctrine.yaml to bring in MATCH AGAINST method 2022-07-29 11:18:20 +01:00
Brabli 709c641fc9 Migrate xml schema 2022-07-21 15:10:19 +01:00
Brabli ff506e2aef Add tests command 2022-07-21 15:10:05 +01:00
Brabli 702fe24be4 Add space 2022-07-21 11:51:47 +01:00
mf 319334834b Merge tag '0.0.16' into develop
Working on DI 0.0.16
2022-07-20 21:29:45 +01:00
mf 9bad1cb2b9 Merge branch 'release/0.0.16' 2022-07-20 21:29:43 +01:00
mf df927a8197 Adding doctrine.yaml config 2022-07-20 21:29:33 +01:00
mf 5c98989631 Merge tag '0.0.15' into develop
Working on DI 0.0.15
2022-07-20 21:26:13 +01:00
mf 373087a5d5 Merge branch 'release/0.0.15' 2022-07-20 21:26:11 +01:00
mf 205b29e07c Working on DependencyInjection 2022-07-20 21:26:00 +01:00
mf e1d5f094de Merge tag '0.0.14' into develop
Working on DI 0.0.14
2022-07-20 21:23:55 +01:00
mf 8145b87242 Merge branch 'release/0.0.14' 2022-07-20 21:23:53 +01:00
mf d027933ad9 Working on DependencyInjection 2022-07-20 21:23:43 +01:00
mf cbad86a01c Merge tag '0.0.13' into develop
Working on DI 0.0.13
2022-07-20 21:21:37 +01:00
mf 15bf7cb324 Merge branch 'release/0.0.13' 2022-07-20 21:21:35 +01:00
mf 1b9787aa49 Working on DependencyInjection 2022-07-20 21:21:15 +01:00
mf 9d4297659d Merge tag '0.0.12' into develop
Working on DI 0.0.12
2022-07-20 21:16:15 +01:00
mf 92842c552b Merge branch 'release/0.0.12' 2022-07-20 21:16:13 +01:00
mf 80e584e29f Working on DependencyInjection 2022-07-20 21:16:05 +01:00
mf c03d2ae9ad Merge tag '0.0.11' into develop
Working on DI 0.0.11
2022-07-20 21:15:24 +01:00
mf c27ae18aa0 Merge branch 'release/0.0.11' 2022-07-20 21:15:22 +01:00
mf 89c88520d9 Working on DependencyInjection 2022-07-20 21:15:13 +01:00
mf c19a755ed1 Merge tag '0.0.10' into develop
Working on DI 0.0.10
2022-07-20 21:09:54 +01:00
mf 12925cc7d7 Merge branch 'release/0.0.10' 2022-07-20 21:09:52 +01:00
mf 393a704f1c Working on DependencyInjection 2022-07-20 21:09:43 +01:00
mf 398e2903ea Merge tag '0.0.9' into develop
Working on DI 0.0.9
2022-07-20 21:06:17 +01:00
mf 95db65f56d Merge branch 'release/0.0.9' 2022-07-20 21:06:16 +01:00
mf 1ec9b05413 Working on DependencyInjection 2022-07-20 21:05:54 +01:00
mf 6a4ce3bef3 Merge tag '0.0.8' into develop
Working on DI
2022-07-20 21:02:39 +01:00
17 changed files with 450 additions and 227 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM php:8.1-alpine FROM php:8.4-alpine
WORKDIR /code WORKDIR /code
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
-8
View File
@@ -1,8 +0,0 @@
PHP = docker compose run php
composer_install:
@$(PHP) composer install
static_analysis:
@$(PHP) vendor/bin/psalm
+86
View File
@@ -0,0 +1,86 @@
# PCM Search Bundle
Provides a search system for Symfony projects.
## Installation
Add the repo to your `composer.json` file:
```json
"repositories": [
{
"type": "vcs",
"url": "https://git.pcmdev.co.uk/pcm-libraries/pcm-search-bundle.git"
}
]
```
Then install the bundle via Composer:
```sh
composer require pcm/search-bundle
```
## Usage
Make any entity searchable by implementing `SearchableInterface`. The bundle's `SearchService` will then index, re-index, and remove entries automatically via Doctrine event subscribers.
To (re)build the index for all searchable entities, run:
```sh
php bin/console pcm:search:reindex
```
Inject `Pcm\SearchBundle\Service\SearchService` into your own services to query, index, or un-index entities programmatically.
### Adding a search bar
Make your entity searchable by implementing `SearchableInterface`:
```php
use Pcm\SearchBundle\Interface\SearchableInterface;
#[ORM\Entity]
class Company implements SearchableInterface
{
public function getSearchTitle(): string
{
return $this->name;
}
public function getSearchValues(): array
{
return [
$this->name,
$this->website
];
}
}
```
Query the index from a controller and resolve each hit to a URL:
```php
#[Route('/search', name: 'app_search', methods: ['GET'])]
public function search(Request $request, SearchIndexRepository $repo): Response
{
$routes = [Company::class => 'app_company_show'];
$rows = $repo->findAllPagination($request->query->get('q', ''))->getResult();
$results = [];
foreach ($rows as [$index]) {
$route = $routes[$index->getEntityClass()] ?? null;
if ($route === null) {
continue;
}
$results[] = [
'title' => $index->getTitle(),
'url' => $this->generateUrl($route, ['id' => $index->getEntityId()]),
];
}
return $this->render('search/_results.html.twig', ['results' => $results]);
}
```
Each row from `findAllPagination()` is `[SearchIndex $hit, 'score' => float]`.
+9 -8
View File
@@ -3,16 +3,17 @@
"description": "Provides a search system for Symfony projects", "description": "Provides a search system for Symfony projects",
"type": "symfony-bundle", "type": "symfony-bundle",
"require": { "require": {
"php": ">=8.0", "php": ">=8.2",
"beberlei/doctrineextensions": "^1.3", "beberlei/doctrineextensions": "^1.5",
"doctrine/doctrine-bundle": "^2.7", "doctrine/doctrine-bundle": "^2.13",
"doctrine/orm": "^2.12", "doctrine/orm": "^2.20 || ^3.3",
"symfony/framework-bundle": "*" "doctrine/persistence": "^3.3 || ^4.0",
"symfony/framework-bundle": "^6.4 || ^7.0"
}, },
"require-dev": { "require-dev": {
"vimeo/psalm": "^4.24", "vimeo/psalm": "^6.0",
"psalm/plugin-symfony": "^3.1", "psalm/plugin-symfony": "^5.2",
"phpunit/phpunit": "^9.5" "phpunit/phpunit": "^11.0 || ^12.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
+16 -4
View File
@@ -1,9 +1,21 @@
services: services:
pcm_search.searchable_subscriber: _defaults:
class: Pcm\SearchBundle\EventSubscriber\SearchableSubscriber autowire: true
autoconfigure: true
pcm_search.command.index:
class: Pcm\SearchBundle\Command\SearchIndexCommand
tags: tags:
- { name: doctrine.event_subscriber } - { name: "console.command", command: "pcm:search:reindex" }
Pcm\SearchBundle\EventSubscriber\SearchableListener: ~
pcm_search.search_service: pcm_search.search_service:
class: Pcm\SearchBundle\Service\SearchService alias: Pcm\SearchBundle\Service\SearchService
public: true public: true
Pcm\SearchBundle\Service\SearchService: ~
Pcm\SearchBundle\Repository\SearchIndexRepository:
autowire: true
tags: ["doctrine.repository_service"]
+17
View File
@@ -0,0 +1,17 @@
php := "docker compose run php"
composer-install:
@{{php}} composer install
composer-update:
@{{php}} composer update
composer *args:
@{{php}} composer {{args}}
static-analysis:
@{{php}} vendor/bin/psalm
tests:
@{{php}} rm -rf var/cache
@{{php}} vendor/bin/phpunit
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true" bootstrap="vendor/autoload.php">
<testsuites>
<testsuite name="Test Suite">
<directory suffix="Test.php">./tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./src</directory>
</whitelist>
</filter>
</phpunit>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="./vendor/autoload.php">
<coverage>
<include>
<directory>./src</directory>
</include>
</coverage>
<php>
<ini name="error_reporting" value="-1"/>
<ini name="intl.default_locale" value="en"/>
<ini name="intl.error_level" value="0"/>
<ini name="memory_limit" value="-1"/>
</php>
<testsuites>
<testsuite name="Test suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
</phpunit>
+36 -7
View File
@@ -2,10 +2,12 @@
namespace Pcm\SearchBundle\Command; namespace Pcm\SearchBundle\Command;
use Doctrine\ORM\EntityManagerInterface;
use Pcm\SearchBundle\Entity\SearchIndex; use Pcm\SearchBundle\Entity\SearchIndex;
use Pcm\SearchBundle\Service\SearchService; use Pcm\SearchBundle\Service\SearchService;
use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@@ -23,7 +25,16 @@ class SearchIndexCommand extends Command
parent::__construct(); parent::__construct();
} }
protected function configure(): void {} protected function configure(): void
{
$this->addOption(
'batchsize',
'b',
InputOption::VALUE_REQUIRED,
'Number of rows to process before flushing',
2000
);
}
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
@@ -42,29 +53,47 @@ class SearchIndexCommand extends Command
$progress_bar->setProgressCharacter("<fg=green>➤</>"); $progress_bar->setProgressCharacter("<fg=green>➤</>");
$progress_bar->start(); $progress_bar->start();
$batchSize = $input->getOption('batchsize');
$searchEntities = null;
foreach ($searchables as $searchable) { foreach ($searchables as $searchable) {
$entities = $this $entities = $this
->em ->em
->getRepository($searchable) ->getRepository($searchable)
->findAll(); ->findAll();
$searchEntities[] = [$searchable, count($entities)];
$this->em->clear();
$counter = 1;
foreach ($entities as $entity) { foreach ($entities as $entity) {
$search_result = $this->searchService->createSearchResult($entity); $search_result = $this->searchService->createSearchResult($entity);
$this->em->persist($search_result); $this->em->persist($search_result);
if (($counter % $batchSize) === 0) {
$this->em->flush();
$this->em->clear();
}
$counter++;
} }
$progress_bar->advance(); $progress_bar->advance();
$this->em->flush();
$this->em->clear();
} }
$this->em->flush();
$progress_bar->finish(); $progress_bar->finish();
$io->writeln('');
$io->writeln(''); $io->writeln('');
$table = new Table($output);
$table
->setHeaders(['Class', 'Count'])
->setRows($searchEntities)
;
$table->render();
$io->writeln('');
$io->success('Index updated'); $io->success('Index updated');
return Command::SUCCESS; return Command::SUCCESS;
+20 -5
View File
@@ -6,17 +6,32 @@ namespace Pcm\SearchBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class PcmSearchExtension extends Extension final class PcmSearchExtension extends Extension implements PrependExtensionInterface
{ {
public function load(array $configs, ContainerBuilder $container) public function prepend(ContainerBuilder $container): void
{ {
$loader = new YamlFileLoader( $container->loadFromExtension(
$container, 'doctrine',
new FileLocator(__DIR__.'/../../config') [
'orm' => [
'dql' => [
'string_functions' => [
'match' => 'DoctrineExtensions\Query\Mysql\MatchAgainst'
]
]
]
]
); );
}
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../../config'));
$loader->load('services.yaml'); $loader->load('services.yaml');
} }
} }
@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace Pcm\SearchBundle\Entity\Interface;
interface SearchableInterface
{
public function getSearchTitle(): string;
public function getSearchValues(): array;
}
+40 -34
View File
@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Pcm\SearchBundle\Entity; namespace Pcm\SearchBundle\Entity;
use Pcm\SearchBundle\Repository\SearchIndexRepository; use Pcm\SearchBundle\Repository\SearchIndexRepository;
@@ -7,67 +9,71 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: SearchIndexRepository::class)] #[ORM\Entity(repositoryClass: SearchIndexRepository::class)]
#[ORM\Index(name: "idx_data_fulltext", columns: ["data"], flags: ["fulltext"])] #[ORM\Index(name: "idx_data_fulltext", columns: ["data"], flags: ["fulltext"])]
class SearchIndex final class SearchIndex
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
private $id; private ?int $id = null;
#[ORM\Column(name: 'data', type: 'text')]
private string $searchText;
#[ORM\Column(type: 'string', length: 255)] #[ORM\Column(type: 'string', length: 255)]
private $entityClass; private string $title;
public function __construct(
#[ORM\Column(type: 'string', length: 512)]
private readonly string $entityClass,
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
private $entityId; private readonly int $entityId,
) {}
#[ORM\Column(type: 'text')]
private $data;
#[ORM\Column(type: 'string', length: 255)]
private $title;
/**
* The primary key of this index row. Null until the row is persisted.
*/
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
} }
public function getEntityClass(): ?string /**
* The fully-qualified class name of the indexed entity.
*/
public function getEntityClass(): string
{ {
return $this->entityClass; return $this->entityClass;
} }
public function setEntityClass(string $entityClass): self /**
{ * The primary key of the indexed entity within its own table.
$this->entityClass = $entityClass; */
public function getEntityId(): int
return $this;
}
public function getEntityId(): ?int
{ {
return $this->entityId; return $this->entityId;
} }
public function setEntityId(int $entityId): self /**
* The concatenated searchable text for this entity. This is the value
* the fulltext index is matched against.
*/
public function getSearchText(): string
{ {
$this->entityId = $entityId; return $this->searchText;
}
public function setSearchText(string $searchText): self
{
$this->searchText = $searchText;
return $this; return $this;
} }
public function getData(): ?string /**
{ * The human-readable title to display for this hit in search results.
return $this->data; */
} public function getTitle(): string
public function setData(string $data): self
{
$this->data = $data;
return $this;
}
public function getTitle(): ?string
{ {
return $this->title; return $this->title;
} }
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Pcm\SearchBundle\EventSubscriber;
use Pcm\SearchBundle\Interface\SearchableInterface;
use Pcm\SearchBundle\Service\SearchService;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::postPersist)]
#[AsDoctrineListener(event: Events::postUpdate)]
#[AsDoctrineListener(event: Events::preRemove)]
final class SearchableListener
{
public function __construct(private SearchService $searchService)
{
}
public function postPersist(PostPersistEventArgs $args): void
{
$this->reindex($args->getObject());
}
public function postUpdate(PostUpdateEventArgs $args): void
{
$this->reindex($args->getObject());
}
public function preRemove(PreRemoveEventArgs $args): void
{
$entity = $args->getObject();
if ($entity instanceof SearchableInterface) {
$this->searchService->unIndex($entity);
}
}
private function reindex(object $entity): void
{
if ($entity instanceof SearchableInterface) {
$this->searchService->index($entity);
}
}
}
@@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace Pcm\SearchBundle\EventSubscriber;
use Pcm\SearchBundle\Entity\Interface\SearchableInterface;
use Pcm\SearchBundle\Service\SearchService;
use Doctrine\Common\EventSubscriber;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Doctrine\ORM\Events;
class SearchableSubscriber implements EventSubscriber
{
public function __construct(private SearchService $searchService) {}
public function getSubscribedEvents()
{
return [
Events::postPersist,
Events::postUpdate,
Events::preRemove
];
}
public function postUpdate(LifecycleEventArgs $args)
{
$entity = $args->getObject();
if ($entity instanceof SearchableInterface) {
$this->searchService->index($entity);
}
}
public function postPersist(LifecycleEventArgs $args)
{
$entity = $args->getObject();
if ($entity instanceof SearchableInterface) {
$this->searchService->index($entity);
}
}
public function preRemove(LifecycleEventArgs $args)
{
$entity = $args->getObject();
if ($entity instanceof SearchableInterface) {
$this->searchService->unIndex($entity);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Pcm\SearchBundle\Interface;
interface SearchableInterface
{
/**
* This entity's ID.
*/
public function getId(): ?int;
/**
* This entity's human-readable title to be shown in search results.
*/
public function getSearchTitle(): string;
/**
* The string values to index for this entity
*
* Each value is concatenated into the fulltext index, so include any field a user might search by.
*
* @return string[]
*/
public function getSearchValues(): array;
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Pcm\SearchBundle\Repository;
use Pcm\SearchBundle\Entity\SearchIndex;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\Persistence\ManagerRegistry;
final class SearchIndexRepository extends ServiceEntityRepository
{
/**
* @param ManagerRegistry $registry
*/
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SearchIndex::class);
}
/**
* Returns a Query object ready to be paginated or used to present results.
*/
public function findAllPagination(string $query, int $minScore = 0): Query
{
return $this->createQueryBuilder('r')
->addSelect('MATCH(r.searchText) AGAINST(:searchText boolean) AS score')
->where('MATCH(r.searchText) AGAINST(:searchText boolean) > :minScore')
->orderBy('score', 'DESC')
->setParameter('searchText', $this->convertSearchTerm($query))
->setParameter('minScore', $minScore)
->getQuery();
}
/**
* Splits the query into words and wraps each as `+WORD*`, so the fulltext
* search requires every word and matches as a wildcard on each word's start.
*/
private function convertSearchTerm(string $query): string
{
$sanitised = preg_replace('/[^\w\d]/', ' ', $query) ?? '';
$words = preg_split('/\s+/', $sanitised, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$extractedWords = [];
foreach ($words as $word) {
$extractedWords[$word] = '+' . $word . '*';
}
return implode(' ', $extractedWords);
}
/**
* Clears the index table of all results
*/
public function clearIndex(): void
{
$this
->createQueryBuilder('s')
->delete()
->getQuery()
->execute();
}
}
+65 -83
View File
@@ -5,113 +5,95 @@ declare(strict_types=1);
namespace Pcm\SearchBundle\Service; namespace Pcm\SearchBundle\Service;
use Pcm\SearchBundle\Entity\SearchIndex; use Pcm\SearchBundle\Entity\SearchIndex;
use Pcm\SearchBundle\Entity\Interface\SearchableInterface; use Pcm\SearchBundle\Interface\SearchableInterface;
use Doctrine\Common\Util\ClassUtils;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Proxy\Proxy;
class SearchService final class SearchService
{ {
public function __construct(private EntityManagerInterface $em) {} public function __construct(private EntityManagerInterface $em)
/**
* Given an $entity that implements SearchableInterface, this method
* creates or updates a SearchIndex $entity
*
* @param SearchableInterface $entity
* @return void
*/
public function index(SearchableInterface $entity)
{ {
$searchIndex = $this->createSearchResult($entity); }
$this->em->persist($searchIndex); public function index(SearchableInterface $entity): void
{
$index = $this->createSearchResult($entity);
$this->em->persist($index);
$this->em->flush();
}
public function unIndex(SearchableInterface $entity): void
{
$entityId = $this->requireEntityId($entity);
$index = $this->findIndex($this->resolveClass($entity), $entityId);
if ($index === null) {
return;
}
$this->em->remove($index);
$this->em->flush(); $this->em->flush();
} }
/** /**
* Given an $entity that implements SearchableInterface, this method removes * Finds or creates the SearchIndex row for the given entity and populates
* the item from the search index * its title and search text from the entity. The returned row is unflushed.
*
* @param SearchableInterface $entity
* @return void
*/
public function unIndex(SearchableInterface $entity)
{
$class = get_class($entity);
$search_result = $this
->em
->getRepository(SearchIndex::class)
->findOneBy(['entityClass' => $class, 'entityId' => $entity->getId()]);
if ($search_result) {
$this->em->remove($search_result);
$this->em->flush();
}
}
/**
* Given an entity that implements SearchableInterface, this method first checks
* if the relevant SearchIndex entity exists. If it doesn't, it's created. The
* title and index data are set based on the methods in the $entity
*
* @param SearchableInterface $entity
* @return SearchIndex
*/ */
public function createSearchResult(SearchableInterface $entity): SearchIndex public function createSearchResult(SearchableInterface $entity): SearchIndex
{ {
$values = []; $entityId = $this->requireEntityId($entity);
$class = $this->resolveClass($entity);
$index = $this->findIndex($class, $entityId) ?? new SearchIndex($class, $entityId);
foreach ($entity->getSearchValues() as $value) { $index->setTitle($entity->getSearchTitle());
$values[] = $value; $index->setSearchText(implode(' ', $entity->getSearchValues()));
}
$data = implode(' ', $values); return $index;
$class = get_class($entity);
if ($entity instanceof Proxy) {
$class = ClassUtils::getRealClass($class);
}
$searchResult = $this
->em
->getRepository(SearchIndex::class)
->findOneBy(
[
'entityClass' => $class,
'entityId' => $entity->getId()
]
);
if (!$searchResult) {
$searchResult = new SearchIndex();
$searchResult->setEntityClass($class);
$searchResult->setEntityId($entity->getId());
}
$searchResult->setTitle($entity->getSearchTitle());
$searchResult->setData($data);
return $searchResult;
} }
/** /**
* Finds all searchable Doctrine entities the implement SearchableInterface * @return class-string[]
* @return array
*/ */
public function getSearchableClasses(): array public function getSearchableClasses(): array
{ {
$metadata = $this->em->getMetadataFactory()->getAllMetadata(); $classes = [];
$searchables = [];
foreach ($metadata as $meta) { foreach ($this->em->getMetadataFactory()->getAllMetadata() as $meta) {
if ($meta->reflClass->implementsInterface(SearchableInterface::class)) { if ($meta->reflClass->implementsInterface(SearchableInterface::class)) {
$searchables[] = $meta->name; $classes[] = $meta->name;
} }
} }
return $searchables; return $classes;
}
private function findIndex(string $class, int $entityId): ?SearchIndex
{
return $this->em
->getRepository(SearchIndex::class)
->findOneBy(['entityClass' => $class, 'entityId' => $entityId]);
}
private function requireEntityId(SearchableInterface $entity): int
{
$entityId = $entity->getId();
if ($entityId === null) {
throw new \LogicException('Entity must be persisted before indexing.');
}
return $entityId;
}
/**
* @template T of object
*
* @param T $entity
*
* @return class-string<T>
*/
private function resolveClass(object $entity): string
{
return $this->em->getClassMetadata($entity::class)->getName();
} }
} }