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

@@ -21,6 +21,8 @@ use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ActivityQueryHydrate;
use App\Repository\Search\SearchConfiguration;
use App\Repository\Search\SearchHelper;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityRepository;
@@ -35,8 +37,6 @@ use Doctrine\ORM\QueryBuilder;
*/
class ActivityRepository extends EntityRepository
{
use RepositorySearchTrait;
/**
* @param int[] $activityIds
* @return array<Activity>
@@ -330,29 +330,17 @@ class ActivityRepository extends EntityRepository
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams(), $query->isGlobalsOnly());
$this->addSearchTerm($qb, $query);
$configuration = new SearchConfiguration(
['a.name', 'a.comment', 'a.number'],
ActivityMeta::class,
'activity'
);
$helper = new SearchHelper($configuration);
$helper->addSearchTerm($qb, $query);
return $qb;
}
private function getMetaFieldClass(): string
{
return ActivityMeta::class;
}
private function getMetaFieldName(): string
{
return 'activity';
}
/**
* @return array<string>
*/
private function getSearchableFields(): array
{
return ['a.name', 'a.comment', 'a.number'];
}
/**
* @return int<0, max>
*/

View File

@@ -21,6 +21,8 @@ use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\CustomerQueryHydrate;
use App\Repository\Search\SearchConfiguration;
use App\Repository\Search\SearchHelper;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityRepository;
@@ -35,8 +37,6 @@ use Doctrine\ORM\QueryBuilder;
*/
class CustomerRepository extends EntityRepository
{
use RepositorySearchTrait;
/**
* @param int[] $customerIDs
* @return array<Customer>
@@ -209,29 +209,17 @@ class CustomerRepository extends EntityRepository
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
$this->addSearchTerm($qb, $query);
$configuration = new SearchConfiguration(
['c.name', 'c.comment', 'c.company', 'c.vatId', 'c.number', 'c.contact', 'c.phone', 'c.email', 'c.address'],
CustomerMeta::class,
'customer'
);
$helper = new SearchHelper($configuration);
$helper->addSearchTerm($qb, $query);
return $qb;
}
private function getMetaFieldClass(): string
{
return CustomerMeta::class;
}
private function getMetaFieldName(): string
{
return 'customer';
}
/**
* @return array<string>
*/
private function getSearchableFields(): array
{
return ['c.name', 'c.comment', 'c.company', 'c.vatId', 'c.number', 'c.contact', 'c.phone', 'c.email', 'c.address'];
}
public function getPagerfantaForQuery(CustomerQuery $query): Pagination
{
return new Pagination($this->getPaginatorForQuery($query), $query);

View File

@@ -17,6 +17,8 @@ use App\Entity\User;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Paginator\QueryPaginator;
use App\Repository\Query\InvoiceArchiveQuery;
use App\Repository\Search\SearchConfiguration;
use App\Repository\Search\SearchHelper;
use App\Utils\Pagination;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Mapping\ClassMetadata;
@@ -28,8 +30,6 @@ use Doctrine\ORM\QueryBuilder;
*/
class InvoiceRepository extends EntityRepository
{
use RepositorySearchTrait;
public function saveInvoice(Invoice $invoice): void
{
$entityManager = $this->getEntityManager();
@@ -235,30 +235,19 @@ class InvoiceRepository extends EntityRepository
if ($query->hasSearchTerm()) {
$qb->leftJoin('i.customer', 'customer');
$this->addSearchTerm($qb, $query);
$configuration = new SearchConfiguration(
['i.comment', 'customer.name', 'customer.company'],
InvoiceMeta::class,
'invoice'
);
$helper = new SearchHelper($configuration);
$helper->addSearchTerm($qb, $query);
}
return $qb;
}
private function getMetaFieldClass(): string
{
return InvoiceMeta::class;
}
private function getMetaFieldName(): string
{
return 'invoice';
}
/**
* @return array<string>
*/
private function getSearchableFields(): array
{
return ['i.comment', 'customer.name', 'customer.company'];
}
/**
* @return int<0, max>
*/

View File

@@ -22,6 +22,8 @@ use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\ProjectQueryHydrate;
use App\Repository\Search\SearchConfiguration;
use App\Repository\Search\SearchHelper;
use App\Utils\Pagination;
use DateTime;
use Doctrine\DBAL\ParameterType;
@@ -38,8 +40,6 @@ use Doctrine\ORM\QueryBuilder;
*/
class ProjectRepository extends EntityRepository
{
use RepositorySearchTrait;
/**
* @param int[] $projectIds
* @return array<Project>
@@ -281,29 +281,17 @@ class ProjectRepository extends EntityRepository
$this->addPermissionCriteria($qb, $query->getCurrentUser());
$this->addSearchTerm($qb, $query);
$configuration = new SearchConfiguration(
['p.name', 'p.comment', 'p.orderNumber', 'p.number'],
ProjectMeta::class,
'project'
);
$helper = new SearchHelper($configuration);
$helper->addSearchTerm($qb, $query);
return $qb;
}
private function getMetaFieldClass(): string
{
return ProjectMeta::class;
}
private function getMetaFieldName(): string
{
return 'project';
}
/**
* @return array<string>
*/
private function getSearchableFields(): array
{
return ['p.name', 'p.comment', 'p.orderNumber', 'p.number'];
}
private function addProjectStartAndEndDate(QueryBuilder $qb, ?DateTime $begin, ?DateTime $end): Andx
{
$and = $qb->expr()->andX();

View File

@@ -10,20 +10,42 @@
namespace App\Repository;
use App\Repository\Query\BaseQuery;
use App\Repository\Search\SearchConfiguration;
use App\Repository\Search\SearchHelper;
use Doctrine\ORM\QueryBuilder;
trait RepositorySearchTrait
/**
* @deprecated since 2.34.0 use SearchHelper instead
*/
trait RepositorySearchTrait // @phpstan-ignore trait.unused
{
/**
* The name of the meta-field class, e.g. ProjectMeta::class.
* Null if entity does not support meta-fields.
*/
private function getMetaFieldClass(): ?string
{
return null;
}
/**
* 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.
*/
private function getMetaFieldName(): ?string
{
return null;
}
/**
* This is the attribute/field name of meta-field class within the entity
* Null if entity does not support meta-fields.
*/
private function getEntityFieldName(): ?string
{
return 'meta';
}
/**
* @return array<string>
*/
@@ -32,97 +54,16 @@ trait RepositorySearchTrait
return [];
}
private function supportsMetaFields(): bool
{
/* @phpstan-ignore-next-line */
return $this->getMetaFieldClass() !== null && $this->getMetaFieldName() !== null;
}
private function addSearchTerm(QueryBuilder $qb, BaseQuery $query): void
{
$searchTerm = $query->getSearchTerm();
$configuration = new SearchConfiguration(
$this->getSearchableFields(),
$this->getMetaFieldClass(),
$this->getMetaFieldName()
);
$configuration->setEntityFieldName($this->getEntityFieldName());
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()) {
$i = 0;
$a = 0;
$c = 0;
foreach ($searchTerm->getSearchFields() as $metaName => $metaValue) {
$and = $qb->expr()->andX();
/** @var non-falsy-string&lowercase-string $alias */
$alias = 'meta' . $a++;
$paramName = 'metaName' . $i++;
$paramValue = 'metaValue' . $c++;
$subqueryName = 'metaNotExists' . $metaName;
if ($metaValue === '*') {
$qb->leftJoin($rootAlias . '.meta', $alias);
$and->add($qb->expr()->eq($alias . '.name', ':' . $paramName));
$qb->setParameter($paramName, $metaName);
$and->add($qb->expr()->isNotNull($alias . '.value'));
} elseif ($metaValue === '~') {
$and->add(
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id)', $subqueryName, $this->getMetaFieldClass(), $subqueryName, $subqueryName, $this->getMetaFieldName(), $rootAlias)
);
} elseif ($metaValue === '' || $metaValue === null) {
$qb->leftJoin($rootAlias . '.meta', $alias);
$and->add(
$qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->eq($alias . '.name', ':' . $paramName),
$qb->expr()->isNull($alias . '.value')
),
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id)', $subqueryName, $this->getMetaFieldClass(), $subqueryName, $subqueryName, $this->getMetaFieldName(), $rootAlias)
)
);
$qb->setParameter($paramName, $metaName);
} else {
$qb->leftJoin($rootAlias . '.meta', $alias);
$and->add($qb->expr()->eq($alias . '.name', ':' . $paramName));
$and->add($qb->expr()->like($alias . '.value', ':' . $paramValue));
$qb->setParameter($paramName, $metaName);
$qb->setParameter($paramValue, '%' . $metaValue . '%');
}
$searchAnd->add($and);
}
}
$fields = $this->getSearchableFields();
if ($searchTerm->hasSearchTerm() && \count($fields) > 0) {
$or = $qb->expr()->orX();
$i = 0;
foreach ($fields as $field) {
$param = 'searchTerm' . $i++;
if (stripos($field, '.') === false) {
$field = $rootAlias . '.' . $field;
}
$or->add(
$qb->expr()->like($field, ':' . $param),
);
$qb->setParameter($param, '%' . $searchTerm->getSearchTerm() . '%');
}
$searchAnd->add($or);
}
if ($searchAnd->count() > 0) {
$qb->andWhere($searchAnd);
}
$helper = new SearchHelper($configuration);
$helper->addSearchTerm($qb, $query);
}
}

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

View File

@@ -14,6 +14,8 @@ use App\Entity\Timesheet;
use App\Repository\Paginator\QueryPaginator;
use App\Repository\Query\TagFormTypeQuery;
use App\Repository\Query\TagQuery;
use App\Repository\Search\SearchConfiguration;
use App\Repository\Search\SearchHelper;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityRepository;
@@ -155,22 +157,9 @@ class TagRepository extends EntityRepository
$qb->addOrderBy($orderBy, $query->getOrder());
$searchTerm = $query->getSearchTerm();
if ($searchTerm !== null) {
$searchAnd = $qb->expr()->andX();
if ($searchTerm->hasSearchTerm()) {
$searchAnd->add(
$qb->expr()->orX(
$qb->expr()->like('tag.name', ':searchTerm')
)
);
$qb->setParameter('searchTerm', '%' . $searchTerm->getSearchTerm() . '%');
}
if ($searchAnd->count() > 0) {
$qb->andWhere($searchAnd);
}
if ($query->hasSearchTerm()) {
$helper = new SearchHelper(new SearchConfiguration(['tag.name']));
$helper->addSearchTerm($qb, $query);
}
return $qb;

View File

@@ -26,6 +26,8 @@ use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Query\TimesheetQueryHint;
use App\Repository\Result\TimesheetResult;
use App\Repository\Search\SearchConfiguration;
use App\Repository\Search\SearchHelper;
use App\Utils\Pagination;
use DateInterval;
use DateTime;
@@ -43,8 +45,6 @@ use InvalidArgumentException;
*/
class TimesheetRepository extends EntityRepository
{
use RepositorySearchTrait;
/** @deprecated since 2.0.35 */
public const STATS_QUERY_DURATION = 'duration';
/** @deprecated since 2.0.35 */
@@ -646,7 +646,13 @@ class TimesheetRepository extends EntityRepository
$requiresTeams = $this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
$this->addSearchTerm($qb, $query);
$configuration = new SearchConfiguration(
['t.description'],
TimesheetMeta::class,
'timesheet'
);
$helper = new SearchHelper($configuration);
$helper->addSearchTerm($qb, $query);
if ($requiresCustomer || $requiresProject || $requiresTeams) {
$qb->leftJoin('t.project', 'p');
@@ -667,24 +673,6 @@ class TimesheetRepository extends EntityRepository
return $qb;
}
private function getMetaFieldClass(): string
{
return TimesheetMeta::class;
}
private function getMetaFieldName(): string
{
return 'timesheet';
}
/**
* @return array<string>
*/
private function getSearchableFields(): array
{
return ['t.description'];
}
/**
* @return Timesheet[]
*/

View File

@@ -20,6 +20,8 @@ use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\UserFormTypeQuery;
use App\Repository\Query\UserQuery;
use App\Repository\Query\VisibilityInterface;
use App\Repository\Search\SearchConfiguration;
use App\Repository\Search\SearchHelper;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\Types;
@@ -323,39 +325,14 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
$qb->setParameter('system', $query->getSystemAccount(), Types::BOOLEAN);
}
$searchTerm = $query->getSearchTerm();
if ($searchTerm !== null) {
$searchAnd = $qb->expr()->andX();
foreach ($searchTerm->getSearchFields() as $metaName => $metaValue) {
$qb->leftJoin('u.preferences', 'meta');
$searchAnd->add(
$qb->expr()->andX(
$qb->expr()->eq('meta.name', ':metaName'),
$qb->expr()->like('meta.value', ':metaValue')
)
);
$qb->setParameter('metaName', $metaName);
$qb->setParameter('metaValue', '%' . $metaValue . '%');
}
if ($searchTerm->hasSearchTerm()) {
$searchAnd->add(
$qb->expr()->orX(
$qb->expr()->like('u.alias', ':searchTerm'),
$qb->expr()->like('u.title', ':searchTerm'),
$qb->expr()->like('u.accountNumber', ':searchTerm'),
$qb->expr()->like('u.email', ':searchTerm'),
$qb->expr()->like('u.username', ':searchTerm')
)
);
$qb->setParameter('searchTerm', '%' . $searchTerm->getSearchTerm() . '%');
}
if ($searchAnd->count() > 0) {
$qb->andWhere($searchAnd);
}
}
$configuration = new SearchConfiguration(
['u.alias', 'u.title', 'u.accountNumber', 'u.email', 'u.username'],
UserPreference::class,
'user'
);
$configuration->setEntityFieldName('preferences');
$helper = new SearchHelper($configuration);
$helper->addSearchTerm($qb, $query);
return $qb;
}

View File

@@ -14,56 +14,92 @@ final class SearchTerm
private string $originalTerm;
private string $term;
/**
* @var string[]
* @var SearchTermPart[]
*/
private array $fields;
private array $parts = [];
public function __construct(string $searchTerm)
{
$this->originalTerm = $searchTerm;
$terms = explode(' ', $searchTerm);
$fields = [];
$finalTerm = [];
foreach ($terms as $term) {
$tmp = explode(':', $term);
if (\count($tmp) === 2) {
$fields[$tmp[0]] = $tmp[1];
} else {
$finalTerm[] = $term;
$part = new SearchTermPart($term);
if ($part->getField() === null) {
$finalTerm[] = $part->getTerm();
}
$this->parts[] = $part;
}
$this->term = implode(' ', $finalTerm);
$this->fields = $fields;
}
/**
* @deprecated since 2.34.0
*/
public function hasSearchField(string $name): bool
{
return \array_key_exists($name, $this->fields);
}
@trigger_error('The SearchTerm::hasSearchField() method is deprecated and will be removed with 3.0', E_USER_DEPRECATED);
public function getSearchField(string $name): ?string
{
if (!$this->hasSearchField($name)) {
return null;
foreach ($this->parts as $part) {
if ($part->getField() === $name) {
return true;
}
}
return $this->fields[$name];
return false;
}
/**
* @deprecated since 2.34.0
*/
public function getSearchField(string $name): ?string
{
@trigger_error('The SearchTerm::getSearchField() method is deprecated and will be removed with 3.0', E_USER_DEPRECATED);
foreach ($this->parts as $part) {
if ($part->getField() === $name) {
return $part->getTerm();
}
}
return null;
}
/**
* @return array<string, string>
*/
public function getSearchFields(): array
{
return $this->fields;
// TODO deprecated 3.0 - all places that use this method should use the RepositorySearchTrait instead (soft deprecation for plugins)
$fields = [];
foreach ($this->parts as $part) {
if (($field = $part->getField()) !== null) {
$fields[$field] = $part->getTerm();
}
}
return $fields;
}
/**
* @return SearchTermPart[]
*/
public function getParts(): array
{
return $this->parts;
}
public function getSearchTerm(): string
{
// TODO deprecated 3.0 - all places that use this method should use the RepositorySearchTrait instead (soft deprecation for plugins)
return $this->term;
}
public function hasSearchTerm(): bool
{
// TODO refactor and use the parts and check if any part has an emoty field name
return $this->term !== '';
}

View File

@@ -0,0 +1,51 @@
<?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\Utils;
final class SearchTermPart
{
private string $term;
private ?string $field = null;
private bool $excluded = false;
public function __construct(string $term)
{
if (str_contains($term, ':')) {
$tmp = explode(':', $term);
// search strings like 'name:' are NOT field searches, but simply terms with a colon
if (\count($tmp) === 2 && $tmp[1] !== '') {
$this->field = $tmp[0];
$term = $tmp[1] !== '""' ? $tmp[1] : '';
}
}
if (\strlen($term) > 1 && $term[0] === '!') {
$term = substr($term, 1);
$this->excluded = true;
}
$this->term = $term;
}
public function getTerm(): string
{
return $this->term;
}
public function getField(): ?string
{
return $this->field;
}
public function isExcluded(): bool
{
return $this->excluded;
}
}