Type to search columns, filters, options, and extensions.

to navigate · Enter to open · Esc to close

Documentation

Search Strategies

Per-column search logic and the global search predicate builder

A server-side table turns two kinds of search into Doctrine conditions, and each has its own extension point:

  • per-column search — the column search box and every ColumnControl logic (equal, starts, empty, …) go through a SearchStrategyInterface, resolved by logic name from a SearchStrategyRegistry.
  • global search — the single search box above the table goes through a SearchPredicateBuilderInterface, which returns one condition per globally searchable column so the caller can OR them together.

Both are supplied by AbstractDataTable hooks, not by service tags.

SearchStrategyInterface

use Doctrine\ORM\QueryBuilder;
use Pentiminax\UX\DataTables\Contracts\ColumnInterface;
use Pentiminax\UX\DataTables\DataTableRequest\ColumnControlSearch;

interface SearchStrategyInterface
{
    public function apply(
        QueryBuilder $qb,
        ColumnInterface $column,
        ColumnControlSearch $search,
        int $paramIndex,
        string $alias,
    ): void;

    public function getLogic(): string;
}

A strategy mutates the QueryBuilder in place, so it only fits criteria that are AND-composed with the rest of the query. Three rules make an implementation safe:

  • Be a no-op for a value you cannot use. An empty term, a column with no field(), or a term that does not parse for the field’s Doctrine type must add no condition at all. Binding a value the driver will reject turns a user typo into a 500.
  • Name every parameter from $paramIndex. The pipeline hands out an index no other call can produce. Deriving a name from the column position instead lets two searches on the same column mint the same placeholder, and the second setParameter() silently overwrites the first.
  • Return the logic identifier from getLogic(). That string is the registry key, and it is the logic value the client submits.

$search->type carries the ColumnControl input type (text, num, date, …), which the shipped strategies use as a hint when the column’s own type is not decisive.

Registry

SearchStrategyRegistry maps a logic identifier to a strategy and falls back to a default strategy — ContainsSearchStrategy — for any logic it does not know.

MethodDescription
register(SearchStrategyInterface)Index a strategy under its own getLogic()
get(string $logic)The strategy for that logic, or the default one
has(string $logic)Whether a strategy is registered for that logic

DefaultSearchStrategyRegistry is the registry the bundle uses. It registers:

StrategyLogic
ContainsSearchStrategycontains (also the fallback for unknown logic)
NullnessSearchStrategyempty, and notEmpty when constructed with true
ComparisonSearchStrategyequal, notEqual, starts, ends, notContains, greater, greaterOrEqual, less, lessOrEqual

ComparisonSearchStrategy is parameterized by a ColumnControlLogic case rather than existing as one class per operator: the enum supplies the SQL operator, the parameter wrapping format, and whether the logic is expressed with LIKE. Constructing it with a logic it cannot express — such as ColumnControlLogic::Empty — throws an InvalidArgumentException.

Beyond ColumnControl, the plain per-column search boxes DataTables sends are applied by ColumnSearchFilter through the registry’s contains strategy. Replacing that strategy therefore changes both search forms at once.

Registering a custom strategy

Override createSearchStrategyRegistry() and register on top of the default registry. Registering a strategy whose getLogic() matches a shipped one replaces it.

use Doctrine\ORM\QueryBuilder;
use Pentiminax\UX\DataTables\Contracts\ColumnInterface;
use Pentiminax\UX\DataTables\Contracts\SearchStrategyInterface;
use Pentiminax\UX\DataTables\DataTableRequest\ColumnControlSearch;
use Pentiminax\UX\DataTables\Query\LikeValueEscaper;
use Pentiminax\UX\DataTables\Query\RelationFieldResolver;

/**
 * Case-insensitive "starts with": the shipped `starts` logic compares with a plain LIKE, which is
 * case-sensitive on MySQL binary collations and on PostgreSQL.
 */
final class CaseInsensitiveStartsSearchStrategy implements SearchStrategyInterface
{
    public function apply(
        QueryBuilder $qb,
        ColumnInterface $column,
        ColumnControlSearch $search,
        int $paramIndex,
        string $alias,
    ): void {
        $value = trim($search->value);
        // Honors the column's setSearchField() override when it has one, and falls back to
        // the displayed field otherwise -- including for a column that implements only
        // ColumnInterface.
        $fieldPath = RelationFieldResolver::resolveSearchField($column);

        if ('' === $value || null === $fieldPath) {
            return;
        }

        // Apply the joins the column declared with addSearchJoin(). Idempotent, and a no-op
        // for a column without the search contract.
        RelationFieldResolver::applySearchJoins($qb, $column);

        // LOWER() on a uuid or a datetime column is rejected by strict engines.
        if (!RelationFieldResolver::supportsTextSearch($qb, $fieldPath)) {
            return;
        }

        $field = RelationFieldResolver::resolve($qb, $alias, $fieldPath);
        $paramName = sprintf('starts_ci_%d', $paramIndex);

        $qb
            ->andWhere(sprintf("LOWER(%s) LIKE :%s ESCAPE '%s'", $field, $paramName, LikeValueEscaper::ESCAPE_CHARACTER))
            ->setParameter($paramName, strtolower(LikeValueEscaper::escape($value)).'%');
    }

    public function getLogic(): string
    {
        return 'starts';
    }
}
use Pentiminax\UX\DataTables\Query\Strategy\DefaultSearchStrategyRegistry;
use Pentiminax\UX\DataTables\Query\Strategy\SearchStrategyRegistry;

protected function createSearchStrategyRegistry(): SearchStrategyRegistry
{
    $registry = new DefaultSearchStrategyRegistry();
    $registry->register(new CaseInsensitiveStartsSearchStrategy());

    return $registry;
}

RelationFieldResolver::resolve() is what makes a dotted field path (author.name) work: it walks the path, reusing the joins already on the query builder and adding the missing ones, and returns the DQL expression for the leaf field. LikeValueEscaper neutralizes % and _ in the user’s term so a search for 50% does not become a wildcard.

The global search box is applied by GlobalSearchFilter, which asks a SearchPredicateBuilderInterface for one condition per globally searchable column and combines them with OR.

use Doctrine\ORM\QueryBuilder;
use Pentiminax\UX\DataTables\Contracts\ColumnInterface;

interface SearchPredicateBuilderInterface
{
    public function build(
        QueryBuilder $qb,
        ColumnInterface $column,
        string $alias,
        string $field,
        string $value,
        string $paramName,
        bool $forceNumeric = false,
    ): ?string;
}

Return null when $value cannot be searched against the field — an unparseable term for the column’s Doctrine type, or a type LIKE cannot be used on. That column is then simply left out of the OR.

DefaultSearchPredicateBuilder consults a SearchableColumnInterface column’s own buildSearchPredicate() first and returns its condition verbatim when it gives one. Otherwise it dispatches on the column type:

ColumnCondition
numeric (or $forceNumeric)exact match when the term is numeric, null otherwise
datenull — a partial date term has no meaningful LIKE
native UUID / ULIDexact match when the term is a well-formed identifier of that type
anything elseLIKE %term% when the field supports text search

$field is resolved by the caller through RelationFieldResolver::resolveSearchField() before the builder is reached, so a setSearchField() override and any addSearchJoin() are already in effect. A column returning null from buildSearchPredicate() has no opinion, not “skip me”: the type dispatch runs. A column implementing only ColumnInterface is searched on getField() with no joins, exactly as before the contract existed.

$forceNumeric lets a caller force numeric handling from an external hint; ContainsSearchStrategy uses it for ColumnControl’s number / numeric / num input types.

Replacing the predicate builder

Override createSearchPredicateBuilder() to add type handling the default builder does not cover:

use Pentiminax\UX\DataTables\Contracts\SearchPredicateBuilderInterface;
use Pentiminax\UX\DataTables\Query\DefaultSearchPredicateBuilder;

protected function createSearchPredicateBuilder(): SearchPredicateBuilderInterface
{
    return new EnumAwareSearchPredicateBuilder(new DefaultSearchPredicateBuilder());
}

Delegating to DefaultSearchPredicateBuilder for everything the custom builder does not handle keeps the UUID, numeric, and date behavior — including the cases where returning null is what prevents a driver error.

See Also