3
0

Compare commits

...

16 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
15 changed files with 317 additions and 293 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM php:8.1-alpine
FROM php:8.4-alpine
WORKDIR /code
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
-16
View File
@@ -1,16 +0,0 @@
PHP = docker compose run php
.PHONY: composer_install composer_update static_analysis tests
composer_install:
@$(PHP) composer install
composer_update:
@$(PHP) composer update
static_analysis:
@$(PHP) vendor/bin/psalm
tests:
@$(PHP) rm -rf var/cache
@$(PHP) vendor/bin/phpunit
+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",
"type": "symfony-bundle",
"require": {
"php": ">=8.0",
"beberlei/doctrineextensions": "^1.3",
"doctrine/doctrine-bundle": "^2.7",
"doctrine/orm": "^2.12",
"symfony/framework-bundle": "*"
"php": ">=8.2",
"beberlei/doctrineextensions": "^1.5",
"doctrine/doctrine-bundle": "^2.13",
"doctrine/orm": "^2.20 || ^3.3",
"doctrine/persistence": "^3.3 || ^4.0",
"symfony/framework-bundle": "^6.4 || ^7.0"
},
"require-dev": {
"vimeo/psalm": "^4.24",
"psalm/plugin-symfony": "^3.1",
"phpunit/phpunit": "^9.5"
"vimeo/psalm": "^6.0",
"psalm/plugin-symfony": "^5.2",
"phpunit/phpunit": "^11.0 || ^12.0"
},
"autoload": {
"psr-4": {
+3 -7
View File
@@ -6,13 +6,9 @@ services:
pcm_search.command.index:
class: Pcm\SearchBundle\Command\SearchIndexCommand
tags:
- { name: 'console.command', command: 'pcm:search:reindex' }
- { name: "console.command", command: "pcm:search:reindex" }
pcm_search.searchable_subscriber:
class: Pcm\SearchBundle\EventSubscriber\SearchableSubscriber
public: true
tags:
- { name: doctrine.event_subscriber }
Pcm\SearchBundle\EventSubscriber\SearchableListener: ~
pcm_search.search_service:
alias: Pcm\SearchBundle\Service\SearchService
@@ -22,4 +18,4 @@ services:
Pcm\SearchBundle\Repository\SearchIndexRepository:
autowire: true
tags: ['doctrine.repository_service']
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
+1
View File
@@ -84,6 +84,7 @@ class SearchIndexCommand extends Command
}
$progress_bar->finish();
$io->writeln('');
$table = new Table($output);
$table
@@ -10,9 +10,9 @@ use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class PcmSearchExtension extends Extension implements PrependExtensionInterface
final class PcmSearchExtension extends Extension implements PrependExtensionInterface
{
public function prepend(ContainerBuilder $container)
public function prepend(ContainerBuilder $container): void
{
$container->loadFromExtension(
'doctrine',
@@ -28,12 +28,10 @@ class PcmSearchExtension extends Extension implements PrependExtensionInterface
);
}
public function load(array $configs, ContainerBuilder $container)
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader(
$container,
new FileLocator(__DIR__.'/../../config')
);
$loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../../config'));
$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
declare(strict_types=1);
namespace Pcm\SearchBundle\Entity;
use Pcm\SearchBundle\Repository\SearchIndexRepository;
@@ -7,67 +9,71 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: SearchIndexRepository::class)]
#[ORM\Index(name: "idx_data_fulltext", columns: ["data"], flags: ["fulltext"])]
class SearchIndex
final class SearchIndex
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[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)]
private $entityClass;
private string $title;
#[ORM\Column(type: 'integer')]
private $entityId;
public function __construct(
#[ORM\Column(type: 'string', length: 512)]
private readonly string $entityClass,
#[ORM\Column(type: 'text')]
private $data;
#[ORM\Column(type: 'string', length: 255)]
private $title;
#[ORM\Column(type: 'integer')]
private readonly int $entityId,
) {}
/**
* The primary key of this index row. Null until the row is persisted.
*/
public function getId(): ?int
{
return $this->id;
}
public function getEntityClass(): ?string
/**
* The fully-qualified class name of the indexed entity.
*/
public function getEntityClass(): string
{
return $this->entityClass;
}
public function setEntityClass(string $entityClass): self
{
$this->entityClass = $entityClass;
return $this;
}
public function getEntityId(): ?int
/**
* The primary key of the indexed entity within its own table.
*/
public function getEntityId(): int
{
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;
}
public function getData(): ?string
{
return $this->data;
}
public function setData(string $data): self
{
$this->data = $data;
return $this;
}
public function getTitle(): ?string
/**
* The human-readable title to display for this hit in search results.
*/
public function getTitle(): string
{
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(): array
{
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;
}
+14 -74
View File
@@ -9,7 +9,7 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\Persistence\ManagerRegistry;
class SearchIndexRepository extends ServiceEntityRepository
final class SearchIndexRepository extends ServiceEntityRepository
{
/**
* @param ManagerRegistry $registry
@@ -19,99 +19,39 @@ class SearchIndexRepository extends ServiceEntityRepository
parent::__construct($registry, SearchIndex::class);
}
/**
* Saves a SearchIndex entity
*
* @param SearchIndex $entity
* @param boolean $flush
* @return void
*/
public function add(SearchIndex $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Removes a SearchIndex entity
*
* @param SearchIndex $entity
* @param boolean $flush
* @return void
*/
public function remove(SearchIndex $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Returns a Query object ready to be paginated or used to present results.
*
* @param string $query
* @param integer $minScore
* @return Query
*/
public function findAllPagination(string $query, int $minScore = 0): Query
{
$qb = $this->createQueryBuilder('r')
->addSelect('MATCH(r.data) AGAINST(:searchText boolean) AS score')
->where(
sprintf(
'MATCH(r.data) AGAINST(:searchText boolean) > %f',
$minScore
)
)->orderBy('score', 'DESC')
->setParameter(
'searchText',
$this->convertSearchTerm($query)
);
return $qb->getQuery();
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();
}
/**
* Takes a string $query and explodes into individual words. Each word
* is then prefixed with + and ends with *, making the full text search
* operate as wildcard on all words
*
* @param string $query
* @return string
* 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 = [];
$sanitisedString = preg_replace('/[^\w^\d]/', ' ', $query);
$words = mb_split('\s', preg_replace(['/([^\w+])/','/(\s+)/'], ' ', $sanitisedString));
foreach ($words as $word) {
if (strlen($word)< 1) {
//
continue;
}
$word = strtoupper($word);
$extractedWords[$word] = $word;
$extractedWords[$word] = '+' . $word . '*';
}
array_walk(
$extractedWords,
function(&$word) {
// require every word but allow matching just the start
$word = '+' . $word . '*';
}
);
return implode(' ', $extractedWords);
}
/**
* Clears the index table of all results
* @return void
*/
public function clearIndex(): void
{
+65 -83
View File
@@ -5,113 +5,95 @@ declare(strict_types=1);
namespace Pcm\SearchBundle\Service;
use Pcm\SearchBundle\Entity\SearchIndex;
use Pcm\SearchBundle\Entity\Interface\SearchableInterface;
use Doctrine\Common\Util\ClassUtils;
use Pcm\SearchBundle\Interface\SearchableInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Proxy\Proxy;
class SearchService
final class SearchService
{
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)
public function __construct(private EntityManagerInterface $em)
{
$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();
}
/**
* Given an $entity that implements SearchableInterface, this method removes
* the item from the search index
*
* @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
* Finds or creates the SearchIndex row for the given entity and populates
* its title and search text from the entity. The returned row is unflushed.
*/
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) {
$values[] = $value;
}
$index->setTitle($entity->getSearchTitle());
$index->setSearchText(implode(' ', $entity->getSearchValues()));
$data = implode(' ', $values);
$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;
return $index;
}
/**
* Finds all searchable Doctrine entities the implement SearchableInterface
* @return array
* @return class-string[]
*/
public function getSearchableClasses(): array
{
$metadata = $this->em->getMetadataFactory()->getAllMetadata();
$searchables = [];
$classes = [];
foreach ($metadata as $meta) {
foreach ($this->em->getMetadataFactory()->getAllMetadata() as $meta) {
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();
}
}