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