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.

Search normalization

Every shipped LIKE logic — contains, starts, ends, notContains — is case-insensitive: the condition is built as LOWER(field) LIKE :param and the bound term is lowercased with mb_strtolower(). A bare LIKE is case-sensitive on PostgreSQL and on MySQL binary collations, so without this the same table answered differently depending on the database.

LOWER() on the column also prevents the database from using a plain index on it. When that matters — a starts search on a large MySQL table with a prefix index, on values whose case is already known — opt the column out:

TextColumn::new('reference', 'Reference')
    ->setSearchNormalization(false);

Opting out restores a bare LIKE, which follows the column’s collation: case-sensitive on PostgreSQL and on binary collations, still case-insensitive on a _ci MySQL collation. The flag trades the guaranteed behavior for index usage; it does not force case sensitivity.

AbstractColumn implements NormalizedSearchColumnInterface and exposes it as setSearchNormalization(). A column class implementing ColumnInterface directly can implement that interface to make the same choice; otherwise it is normalized. setSearchPredicate() stays the escape hatch for anything neither mode expresses.

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;

/**
 * Word-prefix "starts with": the shipped `starts` logic anchors on the beginning of the value,
 * so searching "doe" never matches "John Doe". This one also matches the start of any word.
 */
final class WordPrefixStartsSearchStrategy 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() and LIKE on a uuid or a datetime column are rejected by strict engines.
        if (!RelationFieldResolver::supportsTextSearch($qb, $fieldPath)) {
            return;
        }

        $field = RelationFieldResolver::resolve($qb, $alias, $fieldPath);
        $prefix = sprintf('word_prefix_%d', $paramIndex);
        $anywhere = sprintf('word_anywhere_%d', $paramIndex);
        $escaped = LikeValueEscaper::escape(mb_strtolower($value));
        $escape = LikeValueEscaper::ESCAPE_CHARACTER;

        $qb
            ->andWhere(sprintf(
                "(LOWER(%1\$s) LIKE :%2\$s ESCAPE '%4\$s' OR LOWER(%1\$s) LIKE :%3\$s ESCAPE '%4\$s')",
                $field,
                $prefix,
                $anywhere,
                $escape,
            ))
            ->setParameter($prefix, $escaped.'%')
            ->setParameter($anywhere, '% '.$escaped.'%');
    }

    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 WordPrefixStartsSearchStrategy());

    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 binds to the field’s integer/float type, 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
boolean Doctrine fieldnull — even on BooleanColumn, which is DataTables num for 0/1 sorting
anything elseLOWER(field) LIKE %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.

A mapped boolean, date, or UUID field is skipped on the numeric branch instead of falling through to is_numeric(). BooleanColumn uses the DataTables num type so 0/1 sort in the browser; without that skip, a global search for "1" bound active = '1' and kept every true row alongside the intended text matches.

$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