3
0

First pass of the search bundle

This commit is contained in:
2022-07-20 09:23:11 +01:00
commit 06c5a90214
13 changed files with 392 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/vendor
composer.lock

7
Containerfile Normal file
View 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
View File

@@ -0,0 +1,8 @@
PHP = docker compose run php
composer_install:
@$(PHP) composer install
static_analysis:
@$(PHP) vendor/bin/psalm

0
README.md Normal file
View File

7
compose.yml Normal file
View File

@@ -0,0 +1,7 @@
services:
php:
build:
context: .
dockerfile: Containerfile
volumes:
- ./:/code

31
composer.json Normal file
View 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
View 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>

View 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;
}
}

View 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;
}

View 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;
}
}

View 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
View 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__);
}
}

View 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;
}
}