improved search with negation (#5453)

This commit is contained in:
Kevin Papst
2025-05-05 18:18:00 +02:00
committed by GitHub
parent 2a9de506a1
commit 452a8d9390
20 changed files with 881 additions and 287 deletions

View File

@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Search;
class SearchConfiguration implements SearchConfigurationInterface
{
private string $entityFieldName = 'meta';
/**
* @param array<string> $searchableFields
*/
public function __construct(
private readonly array $searchableFields = [],
private readonly ?string $metaFieldClass = null,
private readonly ?string $metaFieldName = null,
)
{
}
/**
* The name of the meta-field class, e.g. ProjectMeta::class.
* Null if entity does not support meta-fields.
*/
public function getMetaFieldClass(): ?string
{
return $this->metaFieldClass;
}
/**
* This is the attribute/field name of the parent class within the meta-field class from getMetaFieldClass()
* Null if entity does not support meta-fields.
*/
public function getMetaFieldName(): ?string
{
return $this->metaFieldName;
}
public function setEntityFieldName(string $entityFieldName): void
{
$this->entityFieldName = $entityFieldName;
}
/**
* This is the attribute/field name of meta-field class within the entity
* Null if entity does not support meta-fields.
*/
public function getEntityFieldName(): ?string
{
return $this->entityFieldName;
}
/**
* @return array<string>
*/
public function getSearchableFields(): array
{
return $this->searchableFields;
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Search;
interface SearchConfigurationInterface
{
/**
* The name of the meta-field class, e.g. ProjectMeta::class.
* Null if entity does not support meta-fields.
*/
public function getMetaFieldClass(): ?string;
/**
* This is the attribute/field name of the parent class within the meta-field class from getMetaFieldClass()
* Null if entity does not support meta-fields.
*/
public function getMetaFieldName(): ?string;
/**
* This is the attribute/field name of meta-field class within the entity
* Null if entity does not support meta-fields.
*/
public function getEntityFieldName(): ?string;
/**
* @return array<string>
*/
public function getSearchableFields(): array;
}

View File

@@ -0,0 +1,147 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Search;
use App\Repository\Query\BaseQuery;
use App\Repository\RepositoryException;
use Doctrine\ORM\QueryBuilder;
final class SearchHelper
{
public function __construct(private readonly SearchConfigurationInterface $configuration)
{
}
private function supportsMetaFields(): bool
{
return $this->configuration->getMetaFieldClass() !== null && $this->configuration->getMetaFieldName() !== null && $this->configuration->getEntityFieldName() !== null;
}
public function addSearchTerm(QueryBuilder $qb, BaseQuery $query): void
{
$searchTerm = $query->getSearchTerm();
if ($searchTerm === null) {
return;
}
if (!$this->supportsMetaFields() && !$searchTerm->hasSearchTerm()) {
return;
}
$aliases = $qb->getRootAliases();
if (!isset($aliases[0])) {
throw new RepositoryException('No alias was set before invoking addSearchTerm().');
}
$rootAlias = $aliases[0];
$searchAnd = $qb->expr()->andX();
if ($this->supportsMetaFields()) {
$metaFieldRef = $rootAlias . '.' . $this->configuration->getEntityFieldName();
$i = 0;
$c = 0;
$j = 0;
foreach ($searchTerm->getParts() as $part) {
// we do NOT search for unspecific/global terms as of now, because it is not clear if the user wants that
if (($metaName = $part->getField()) === null) {
continue;
}
$alias = 'meta' . $j++;
$qb->leftJoin($metaFieldRef, $alias);
$metaValue = $part->getTerm();
$paramName = 'metaName' . $i++;
$paramValue = 'metaValue' . $c++;
$subqueryName = 'metaNotExists' . $metaName;
$field = $alias . '.value';
$and = $qb->expr()->andX();
if ($metaValue === '*') {
$and->add($qb->expr()->eq($alias . '.name', ':' . $paramName));
$and->add($qb->expr()->isNotNull($field));
} elseif ($metaValue === '~') {
$and->add(
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id AND %s.name = :%s)', $subqueryName, $this->configuration->getMetaFieldClass(), $subqueryName, $subqueryName, $this->configuration->getMetaFieldName(), $rootAlias, $subqueryName, $paramName)
);
} elseif ($metaValue === '') {
$and->add(
$qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->eq($alias . '.name', ':' . $paramName),
$qb->expr()->isNull($field)
),
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id AND %s.name = :%s)', $subqueryName, $this->configuration->getMetaFieldClass(), $subqueryName, $subqueryName, $this->configuration->getMetaFieldName(), $rootAlias, $subqueryName, $paramName)
)
);
} else {
$and->add($qb->expr()->eq($alias . '.name', ':' . $paramName));
if (!$part->isExcluded()) {
$and->add($qb->expr()->like($field, ':' . $paramValue));
} else {
$and->add(
$qb->expr()->orX()->addMultiple([
$qb->expr()->isNull($field),
$qb->expr()->notLike($field, ':' . $paramValue),
])
);
}
$qb->setParameter($paramValue, '%' . $metaValue . '%');
}
$qb->setParameter($paramName, $metaName);
$searchAnd->add($and);
}
}
if ($searchTerm->hasSearchTerm()) {
$i = 0;
$fields = $this->configuration->getSearchableFields();
$and = $qb->expr()->andX();
foreach ($searchTerm->getParts() as $part) {
// currently only meta fields have a name, so we do not use them here
if ($part->getField() !== null) {
continue;
}
$or = $qb->expr()->orX();
foreach ($fields as $field) {
if (stripos($field, '.') === false) {
$field = $rootAlias . '.' . $field;
}
$param = 'searchTerm' . $i++;
if ($part->isExcluded()) {
$searchAnd->add(
$qb->expr()->orX()->addMultiple([
$qb->expr()->isNull($field),
$qb->expr()->notLike($field, ':' . $param),
])
);
} else {
$or->add(
$qb->expr()->like($field, ':' . $param),
);
}
$qb->setParameter($param, '%' . $part->getTerm() . '%');
}
if ($or->count() > 0) {
$and->add($or);
}
}
if ($and->count() > 0) {
$searchAnd->add($and);
}
}
if ($searchAnd->count() > 0) {
$qb->andWhere($searchAnd);
}
}
}