Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e2c241349 | |||
| 802413f754 | |||
| e56ea29018 | |||
| 3faec6370c | |||
| 361cd7baf8 | |||
| 6111bd07e5 | |||
| 76ab724d1a | |||
| d7a7d0b6e8 | |||
| 3f57c78d01 | |||
| e88da82267 | |||
|
|
709c641fc9 | ||
|
|
ff506e2aef | ||
|
|
702fe24be4 | ||
| 319334834b | |||
| 9bad1cb2b9 | |||
| df927a8197 | |||
| 5c98989631 | |||
| 373087a5d5 | |||
| 205b29e07c | |||
| e1d5f094de | |||
| 8145b87242 | |||
| d027933ad9 | |||
| cbad86a01c | |||
| 15bf7cb324 | |||
| 1b9787aa49 | |||
| 9d4297659d | |||
| 92842c552b | |||
| 80e584e29f | |||
| c03d2ae9ad | |||
| c27ae18aa0 | |||
| 89c88520d9 | |||
| c19a755ed1 | |||
| 12925cc7d7 | |||
| 393a704f1c | |||
| 398e2903ea | |||
| 95db65f56d | |||
| 1ec9b05413 | |||
| 6a4ce3bef3 | |||
| bf89b962ec | |||
| 7de30e78ac | |||
| 6d65df0b9c | |||
| 5d56d98325 | |||
| 2f6597a957 | |||
| 93bb85f69f | |||
| 218b174d4f | |||
| 2258fa0c96 | |||
| 34b0e7f146 | |||
| ab25540462 | |||
| da950685c0 | |||
| 954e6aa712 | |||
| 3f410b8f55 | |||
| 78f356f030 | |||
| c35da20deb | |||
| 066e3f0d4b | |||
| cf03ff8f89 | |||
| c46af9c4f7 | |||
| 7e3b74af08 | |||
| d4dc1b57c8 |
14
.editorconfig
Normal file
14
.editorconfig
Normal file
@@ -0,0 +1,14 @@
|
||||
# This is the top-most .editorconfig file; do not search in parent directories.
|
||||
root = true
|
||||
|
||||
# All files.
|
||||
[*]
|
||||
end_of_line = LF
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/vendor
|
||||
composer.lock
|
||||
.phpunit.result.cache
|
||||
|
||||
@@ -4,4 +4,4 @@ WORKDIR /code
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
COPY ./ /code
|
||||
|
||||
RUN composer install
|
||||
RUN composer install
|
||||
|
||||
10
Makefile
10
Makefile
@@ -1,8 +1,16 @@
|
||||
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
|
||||
@$(PHP) vendor/bin/psalm
|
||||
|
||||
tests:
|
||||
@$(PHP) rm -rf var/cache
|
||||
@$(PHP) vendor/bin/phpunit
|
||||
|
||||
@@ -9,11 +9,21 @@
|
||||
"doctrine/orm": "^2.12",
|
||||
"symfony/framework-bundle": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"vimeo/psalm": "^4.24",
|
||||
"psalm/plugin-symfony": "^3.1",
|
||||
"phpunit/phpunit": "^9.5"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Pcm\\SearchBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Pcm\\SearchBundle\\": "tests/"
|
||||
}
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Feeny",
|
||||
@@ -23,9 +33,5 @@
|
||||
"name": "Bradley Goode",
|
||||
"email": "bg@pcmsystems.co.uk"
|
||||
}
|
||||
],
|
||||
"require-dev": {
|
||||
"vimeo/psalm": "^4.24",
|
||||
"psalm/plugin-symfony": "^3.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
25
config/services.yaml
Normal file
25
config/services.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
pcm_search.command.index:
|
||||
class: Pcm\SearchBundle\Command\SearchIndexCommand
|
||||
tags:
|
||||
- { name: 'console.command', command: 'pcm:search:reindex' }
|
||||
|
||||
pcm_search.searchable_subscriber:
|
||||
class: Pcm\SearchBundle\EventSubscriber\SearchableSubscriber
|
||||
public: true
|
||||
tags:
|
||||
- { name: doctrine.event_subscriber }
|
||||
|
||||
pcm_search.search_service:
|
||||
alias: Pcm\SearchBundle\Service\SearchService
|
||||
public: true
|
||||
|
||||
Pcm\SearchBundle\Service\SearchService: ~
|
||||
|
||||
Pcm\SearchBundle\Repository\SearchIndexRepository:
|
||||
autowire: true
|
||||
tags: ['doctrine.repository_service']
|
||||
19
phpunit.xml.dist
Normal file
19
phpunit.xml.dist
Normal 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>
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace Pcm\SearchBundle\Command;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Pcm\SearchBundle\Entity\SearchIndex;
|
||||
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\Input\InputOption;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
@@ -23,7 +25,16 @@ class SearchIndexCommand extends Command
|
||||
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
|
||||
{
|
||||
@@ -42,29 +53,47 @@ class SearchIndexCommand extends Command
|
||||
$progress_bar->setProgressCharacter("<fg=green>➤</>");
|
||||
$progress_bar->start();
|
||||
|
||||
$batchSize = $input->getOption('batchsize');
|
||||
$searchEntities = null;
|
||||
|
||||
foreach ($searchables as $searchable) {
|
||||
$entities = $this
|
||||
->em
|
||||
->getRepository($searchable)
|
||||
->findAll();
|
||||
|
||||
$searchEntities[] = [$searchable, count($entities)];
|
||||
|
||||
$this->em->clear();
|
||||
$counter = 1;
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$search_result = $this->searchService->createSearchResult($entity);
|
||||
|
||||
$this->em->persist($search_result);
|
||||
|
||||
if (($counter % $batchSize) === 0) {
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
}
|
||||
$counter++;
|
||||
}
|
||||
|
||||
$progress_bar->advance();
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$progress_bar->finish();
|
||||
|
||||
$io->writeln('');
|
||||
$io->writeln('');
|
||||
|
||||
$table = new Table($output);
|
||||
$table
|
||||
->setHeaders(['Class', 'Count'])
|
||||
->setRows($searchEntities)
|
||||
;
|
||||
$table->render();
|
||||
|
||||
$io->writeln('');
|
||||
$io->success('Index updated');
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
39
src/DependencyInjection/PcmSearchExtension.php
Normal file
39
src/DependencyInjection/PcmSearchExtension.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pcm\SearchBundle\DependencyInjection;
|
||||
|
||||
use Symfony\Component\Config\FileLocator;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
|
||||
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
|
||||
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
|
||||
|
||||
class PcmSearchExtension extends Extension implements PrependExtensionInterface
|
||||
{
|
||||
public function prepend(ContainerBuilder $container)
|
||||
{
|
||||
$container->loadFromExtension(
|
||||
'doctrine',
|
||||
[
|
||||
'orm' => [
|
||||
'dql' => [
|
||||
'string_functions' => [
|
||||
'match' => 'DoctrineExtensions\Query\Mysql\MatchAgainst'
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function load(array $configs, ContainerBuilder $container)
|
||||
{
|
||||
$loader = new YamlFileLoader(
|
||||
$container,
|
||||
new FileLocator(__DIR__.'/../../config')
|
||||
);
|
||||
$loader->load('services.yaml');
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ class SearchableSubscriber implements EventSubscriber
|
||||
{
|
||||
public function __construct(private SearchService $searchService) {}
|
||||
|
||||
public function getSubscribedEvents()
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::postPersist,
|
||||
|
||||
@@ -3,7 +3,7 @@ namespace Pcm\SearchBundle;
|
||||
|
||||
use Symfony\Component\HttpKernel\Bundle\Bundle;
|
||||
|
||||
class SearchBundle extends Bundle
|
||||
class PcmSearchBundle extends Bundle
|
||||
{
|
||||
public function getPath(): string
|
||||
{
|
||||
|
||||
124
src/Repository/SearchIndexRepository.php
Normal file
124
src/Repository/SearchIndexRepository.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?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;
|
||||
|
||||
class SearchIndexRepository extends ServiceEntityRepository
|
||||
{
|
||||
/**
|
||||
* @param ManagerRegistry $registry
|
||||
*/
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
private function convertSearchTerm(string $query): string
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$this
|
||||
->createQueryBuilder('s')
|
||||
->delete()
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,14 @@ class SearchService
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $em) {}
|
||||
|
||||
public function index($entity)
|
||||
/**
|
||||
* 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);
|
||||
|
||||
@@ -22,7 +29,14 @@ class SearchService
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function unIndex($entity)
|
||||
/**
|
||||
* 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);
|
||||
|
||||
@@ -37,6 +51,14 @@ class SearchService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$values = [];
|
||||
@@ -76,7 +98,8 @@ class SearchService
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all searchable Doctrine entities.
|
||||
* Finds all searchable Doctrine entities the implement SearchableInterface
|
||||
* @return array
|
||||
*/
|
||||
public function getSearchableClasses(): array
|
||||
{
|
||||
|
||||
0
tests/.gitkeep
Normal file
0
tests/.gitkeep
Normal file
Reference in New Issue
Block a user