First pass of the search bundle
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/vendor
|
||||
composer.lock
|
||||
7
Containerfile
Normal file
7
Containerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM php:8.1-alpine
|
||||
|
||||
WORKDIR /code
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
COPY ./ /code
|
||||
|
||||
RUN composer install
|
||||
8
Makefile
Normal file
8
Makefile
Normal file
@@ -0,0 +1,8 @@
|
||||
PHP = docker compose run php
|
||||
|
||||
|
||||
composer_install:
|
||||
@$(PHP) composer install
|
||||
|
||||
static_analysis:
|
||||
@$(PHP) vendor/bin/psalm
|
||||
7
compose.yml
Normal file
7
compose.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
php:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Containerfile
|
||||
volumes:
|
||||
- ./:/code
|
||||
31
composer.json
Normal file
31
composer.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "pcm/search-bundle",
|
||||
"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": "*"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Pcm\\SearchBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Feeny",
|
||||
"email": "mf@pcmsystems.co.uk"
|
||||
},
|
||||
{
|
||||
"name": "Bradley Goode",
|
||||
"email": "bg@pcmsystems.co.uk"
|
||||
}
|
||||
],
|
||||
"require-dev": {
|
||||
"vimeo/psalm": "^4.24",
|
||||
"psalm/plugin-symfony": "^3.1"
|
||||
}
|
||||
}
|
||||
15
psalm.xml
Normal file
15
psalm.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0"?>
|
||||
<psalm
|
||||
errorLevel="1"
|
||||
resolveFromConfigFile="true"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="https://getpsalm.org/schema/config"
|
||||
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
|
||||
>
|
||||
<projectFiles>
|
||||
<directory name="src"/>
|
||||
<ignoreFiles>
|
||||
<directory name="vendor"/>
|
||||
</ignoreFiles>
|
||||
</projectFiles>
|
||||
<plugins><pluginClass class="Psalm\SymfonyPsalmPlugin\Plugin"/></plugins></psalm>
|
||||
72
src/Command/SearchIndexCommand.php
Normal file
72
src/Command/SearchIndexCommand.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace Pcm\SearchBundle\Command;
|
||||
|
||||
use Pcm\SearchBundle\Entity\SearchIndex;
|
||||
use Pcm\SearchBundle\Service\SearchService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'pcm:search:reindex',
|
||||
description: 'Builds search index',
|
||||
)]
|
||||
class SearchIndexCommand extends Command
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $em, private SearchService $searchService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
/** @var SearchIndexRepository */
|
||||
$repo = $this->em->getRepository(SearchIndex::class);
|
||||
$repo->clearIndex();
|
||||
|
||||
$searchables = $this->searchService->getSearchableClasses();
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('Indexing');
|
||||
|
||||
$progress_bar = new ProgressBar($output, count($searchables));
|
||||
$progress_bar->setBarCharacter('<fg=green>•</>');
|
||||
$progress_bar->setEmptyBarCharacter("<fg=red>•</>");
|
||||
$progress_bar->setProgressCharacter("<fg=green>➤</>");
|
||||
$progress_bar->start();
|
||||
|
||||
foreach ($searchables as $searchable) {
|
||||
$entities = $this
|
||||
->em
|
||||
->getRepository($searchable)
|
||||
->findAll();
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$search_result = $this->searchService->createSearchResult($entity);
|
||||
|
||||
$this->em->persist($search_result);
|
||||
|
||||
}
|
||||
|
||||
$progress_bar->advance();
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$progress_bar->finish();
|
||||
|
||||
$io->writeln('');
|
||||
$io->writeln('');
|
||||
|
||||
$io->success('Index updated');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
11
src/Entity/Interface/SearchableInterface.php
Normal file
11
src/Entity/Interface/SearchableInterface.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pcm\SearchBundle\Entity\Interface;
|
||||
|
||||
interface SearchableInterface
|
||||
{
|
||||
public function getSearchTitle(): string;
|
||||
public function getSearchValues(): array;
|
||||
}
|
||||
81
src/Entity/SearchIndex.php
Normal file
81
src/Entity/SearchIndex.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Pcm\SearchBundle\Entity;
|
||||
|
||||
use Pcm\SearchBundle\Repository\SearchIndexRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SearchIndexRepository::class)]
|
||||
#[ORM\Index(name: "idx_data_fulltext", columns: ["data"], flags: ["fulltext"])]
|
||||
class SearchIndex
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private $id;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private $entityClass;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private $entityId;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private $data;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private $title;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEntityClass(): ?string
|
||||
{
|
||||
return $this->entityClass;
|
||||
}
|
||||
|
||||
public function setEntityClass(string $entityClass): self
|
||||
{
|
||||
$this->entityClass = $entityClass;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEntityId(): ?int
|
||||
{
|
||||
return $this->entityId;
|
||||
}
|
||||
|
||||
public function setEntityId(int $entityId): self
|
||||
{
|
||||
$this->entityId = $entityId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getData(): ?string
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
public function setData(string $data): self
|
||||
{
|
||||
$this->data = $data;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): self
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
52
src/EventSubscriber/SearchableSubscriber.php
Normal file
52
src/EventSubscriber/SearchableSubscriber.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/PcmSearchBundle.php
Normal file
12
src/PcmSearchBundle.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace Pcm\SearchBundle;
|
||||
|
||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
||||
|
||||
class SearchBundle extends Bundle
|
||||
{
|
||||
public function getPath(): string
|
||||
{
|
||||
return \dirname(__DIR__);
|
||||
}
|
||||
}
|
||||
94
src/Service/SearchService.php
Normal file
94
src/Service/SearchService.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
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 Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Proxy\Proxy;
|
||||
|
||||
class SearchService
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $em) {}
|
||||
|
||||
public function index($entity)
|
||||
{
|
||||
$searchIndex = $this->createSearchResult($entity);
|
||||
|
||||
$this->em->persist($searchIndex);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function unIndex($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();
|
||||
}
|
||||
}
|
||||
|
||||
public function createSearchResult(SearchableInterface $entity): SearchIndex
|
||||
{
|
||||
$values = [];
|
||||
|
||||
foreach ($entity->getSearchValues() as $value) {
|
||||
$values[] = $value;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all searchable Doctrine entities.
|
||||
*/
|
||||
public function getSearchableClasses(): array
|
||||
{
|
||||
$metadata = $this->em->getMetadataFactory()->getAllMetadata();
|
||||
$searchables = [];
|
||||
|
||||
foreach ($metadata as $meta) {
|
||||
if ($meta->reflClass->implementsInterface(SearchableInterface::class)) {
|
||||
$searchables[] = $meta->name;
|
||||
}
|
||||
}
|
||||
|
||||
return $searchables;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user