Framework Bridges

Bridges allow you to apply derafu/query expressions to existing third-party query builder instances — without replacing them. You keep your existing query builder and add Derafu filters on top.

All bridges accept a parsed ConditionInterface | CompositeConditionInterface and apply it to the query builder. You parse expressions with CompositeExpressionParser and pass the result to the bridge.


Shared Setup: The Expression Parser

All bridges share the same expression parser setup:

use Derafu\Query\Filter\CompositeExpressionParser;
use Derafu\Query\Filter\ExpressionParser;
use Derafu\Query\Filter\FilterParser;
use Derafu\Query\Filter\PathParser;
use Derafu\Query\Operator\OperatorLoader;
use Derafu\Query\Operator\OperatorManager;

$loader  = new OperatorLoader();
$manager = new OperatorManager(
    $loader->loadFromFile('vendor/derafu/query/resources/operators.yaml')
);
$parser = new CompositeExpressionParser(
    new ExpressionParser(new PathParser(), new FilterParser($manager))
);

Doctrine DBAL Bridge

Class: Derafu\Query\Bridge\DoctrineDBALQueryBuilderConditionApplier

Applies conditions to a Doctrine DBAL QueryBuilder. Supports all operators including date, regex, and bitwise operators.

Methods

public function apply(object $queryBuilder, ConditionInterface|CompositeConditionInterface $condition): void
public function applyHaving(object $queryBuilder, ConditionInterface|CompositeConditionInterface $condition): void

Usage

use Derafu\Query\Bridge\DoctrineDBALQueryBuilderConditionApplier;

$applier = new DoctrineDBALQueryBuilderConditionApplier();

// Parse the expression.
$condition = $parser->parse('status?=active&&total?>1000');

// Apply to an existing DBAL QueryBuilder.
$applier->apply($dbalQb, $condition);

// The QB now has: WHERE (status = :param_status_... AND total > :param_total_...)
$rows = $dbalQb->executeQuery()->fetchAllAssociative();

FROM and JOIN Inference

When a condition uses multi-segment paths, the bridge:

  1. Infers FROM from the first segment of the first multi-segment path, if no FROM has been set yet.
  2. Generates JOINs from intermediate segments, with deduplication (same alias → skip).
// No from() call needed — it's inferred from the path.
$condition = $parser->parse(
    'customers[alias:c]__invoices[on:id=customer_id,alias:i]__total?>1000'
);
$applier->apply($dbalQb, $condition);
// Sets FROM customers AS c, adds INNER JOIN invoices AS i ON c.id = i.customer_id
// WHERE i.total > :p

Driver Detection

The bridge reads the database platform from the DBAL connection (via reflection on the internal connection property) and maps it to the driver name used by SqlBuilderWhere:

Platform Driver
PostgreSQLPlatform pgsql
MySQLPlatform mysql
SQLitePlatform sqlite
SQLServerPlatform sqlsrv
OraclePlatform oci
Other pgsql (fallback)

HAVING

$dbalQb->groupBy('status');
$condition = $parser->parse('COUNT(*)?>1');
$applier->applyHaving($dbalQb, $condition);

Doctrine ORM Bridge

Class: Derafu\Query\Bridge\DoctrineORMQueryBuilderConditionApplier

Applies conditions to a Doctrine ORM QueryBuilder as DQL. This bridge generates DQL-compatible fragments — it does not produce raw SQL.

Methods

public function apply(object $queryBuilder, ConditionInterface|CompositeConditionInterface $condition): void
public function applyHaving(object $queryBuilder, ConditionInterface|CompositeConditionInterface $condition): void

Usage

use Derafu\Query\Bridge\DoctrineORMQueryBuilderConditionApplier;

$applier = new DoctrineORMQueryBuilderConditionApplier();

$condition = $parser->parse('status?=active&&total?>1000');

$ormQb = $em->createQueryBuilder()
    ->select('i')
    ->from(Invoice::class, 'i');

$applier->apply($ormQb, $condition);

$invoices = $ormQb->getQuery()->getResult();

Single-Segment Path Qualification

Single-segment paths like status are automatically qualified with the root entity alias. The root alias is read from the first FROM clause:

status?=active   →   i.status = :param_status_...   (when root alias is 'i')

Multi-segment paths are left as-is, allowing explicit alias control.

JOIN Generation

The ORM bridge adds JOINs to the QueryBuilder using Doctrine’s association names — the on: option in path segments is ignored. Doctrine derives join conditions from entity mappings.

invoices[alias:i]__payments[alias:p]__status

If Invoice has a payments association, the bridge calls:

$ormQb->innerJoin('i.payments', 'p');

EXISTS Paths in DQL

Subquery paths (___) are translated to DQL:

Expression DQL
___payments?is:empty SIZE(i.payments) = 0
___payments?isnot:empty SIZE(i.payments) > 0
___payments__status?=pending EXISTS(SELECT 1 FROM App\Entity\Invoice source JOIN source.payments payment WHERE source.id = i.id AND payment.status = :p)
___payments__AVG(amount)?<1000 EXISTS(SELECT AVG(payment.amount) FROM App\Entity\Invoice source JOIN source.payments payment WHERE source.id = i.id HAVING AVG(payment.amount) < :p)
___payments__COUNT(*)?=0 SIZE(i.payments) = 0 (optimized)

These fragments are schematic: aliases are generated internally, and the correlation uses all mapped identifiers of the root entity rather than assuming an id property. Association mappings determine the JOINs, including inverse, unidirectional and nested relationships.

SIZE() is used for a single collection association and eligible existence/count comparisons. Single-valued or nested associations use correlated EXISTS / NOT EXISTS. Other aggregate comparisons use EXISTS with HAVING; COUNT(*) becomes COUNT(associationAlias) in DQL.

Numeric aggregate operands retain their decimal string representation. In SQLite and PostgreSQL, the ORM numeric binding type emits CAST(parameter AS NUMERIC). Numeric comparisons include list and range operands. MIN / MAX preserve the mapped field type, so text and dates are not indiscriminately cast to numbers.

Case-Sensitive LIKE on MySQL

like:, notlike: and their auto-pattern aliases are supported. The bridge registers DERAFU_MYSQL_BINARY_STRING automatically and uses a DQL template such as:

i.name LIKE DERAFU_MYSQL_BINARY_STRING(:pattern)

Doctrine translates the function to CAST(parameter AS BINARY) in MySQL SQL. Consumers do not need to register it or change their expressions.

DQL-Incompatible Operators

The following operator types throw UnsupportedOperatorException when used with this bridge:

  • date type: date:, month:, year:, period: — require SQL functions not available in DQL.
  • binary type: b&, b|, b^, etc. — bitwise SQL not supported in DQL.
  • regexp type: ~, ~*, similarto:, etc. — database-specific regex not in DQL.
  • ilike: and notilike:, including their auto-pattern aliases (^*, ~~*, $* and negations) — ILIKE is not DQL.

In the API Platform SmartFilter, these exceptions are caught and the filter is silently skipped.


Illuminate (Laravel) Bridge

Class: Derafu\Query\Bridge\IlluminateQueryBuilderConditionApplier

Applies conditions to Illuminate’s Query\Builder or Eloquent’s Builder. Both are accepted. The bridge modifies Eloquent’s original underlying Query\Builder through getQuery(). Eloquent applies its global scopes when executing the query, so scopes and Derafu filters remain in effect together.

Methods

public function apply(object $queryBuilder, ConditionInterface|CompositeConditionInterface $condition): void
public function applyHaving(object $queryBuilder, ConditionInterface|CompositeConditionInterface $condition): void

Usage

use Derafu\Query\Bridge\IlluminateQueryBuilderConditionApplier;

$applier = new IlluminateQueryBuilderConditionApplier();

$condition = $parser->parse('status?=active&&total?>1000');

// With a plain Query\Builder.
$qb = DB::table('invoices');
$applier->apply($qb, $condition);
$rows = $qb->get();

// With an Eloquent Builder.
$builder = Invoice::query();
$applier->apply($builder, $condition);
$invoices = $builder->get();

Parameter Bindings

The shared SQL compiler produces named parameters. The Illuminate bridge converts their occurrences to positional ? placeholders and appends the corresponding values to the appropriate where or having binding group. Repeated parameters receive a binding for every occurrence, in SQL order. Existing bindings, including those on FROM subqueries, are preserved.

FROM and JOIN Inference

Same behavior as the DBAL bridge: infers the FROM table from multi-segment paths and adds join(), leftJoin(), or rightJoin() calls as needed. Duplicate joins (same table reference) are skipped.

Driver Detection

The bridge reads the driver name directly from Connection::getDriverName():

Driver name SQL generated
pgsql PostgreSQL SQL
mysql MySQL SQL
sqlite SQLite SQL

HAVING

$condition = $parser->parse('AVG(price)?>500');
$applier->applyHaving($qb, $condition);
// Adds a compiled condition and appends its positional bindings to HAVING.

API Platform Bridge

Class: Derafu\Query\Bridge\ApiPlatform\SmartFilter

Integrates derafu/query as an API Platform filter via the QueryParameter attribute. When a request comes in, the filter builds a Derafu expression from the parameter’s property name and value, parses it, and applies it to the ORM QueryBuilder.

Registration

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\QueryParameter;
use Derafu\Query\Bridge\ApiPlatform\SmartFilter;

#[ApiResource]
#[QueryParameter(key: 'price',  property: 'price',  filter: SmartFilter::class)]
#[QueryParameter(key: 'status', property: 'status', filter: SmartFilter::class)]
class Product
{
    // …
}

URL Filter Syntax

The URL parameter value is the filter part (operator + value); the property name provides the path segment:

GET /api/products?price=>1000&status=in:paid,issued

The SmartFilter builds expressions internally:

  • price property + >1000 value → expression price?>1000
  • status property + in:paid,issued value → expression status?in:paid,issued

Generic SmartFilter Property

For a single parameter that accepts a full composite expression, use the special __derafu_smart_filter property name:

#[QueryParameter(key: 'filter', property: '__derafu_smart_filter', filter: SmartFilter::class)]
GET /api/products?filter=status%3F%3Dactive%26%26price%3F%3E1000

The decoded filter value is status?=active&&price?>1000. Encode the entire parameter value when building the URL; otherwise & separates HTTP query parameters instead of remaining part of the filter. For example, use URLSearchParams in JavaScript or http_build_query() in PHP.

Dependency Injection (Symfony)

Register CompositeExpressionParser and DoctrineORMQueryBuilderConditionApplier as services and inject them into SmartFilter:

# services.yaml
services:
    Derafu\Query\Bridge\ApiPlatform\SmartFilter:
        arguments:
            $compositeParser: '@Derafu\Query\Filter\Contract\CompositeExpressionParserInterface'
            $applier:         '@Derafu\Query\Bridge\DoctrineORMQueryBuilderConditionApplier'

Error Handling

SmartFilter silently catches two exceptions:

  • UnsupportedOperatorException: DQL-incompatible operators (date:, b&, ilike:, regex) — the filter is skipped.
  • Any Throwable: Malformed expressions — the filter is skipped.

These catches surround expression parsing and condition application. An absent parameter, an empty/non-string property or an empty/non-string value is ignored. A non-null $context['parameter'] of the wrong type is rejected before the catches; it must be an ApiPlatform\Metadata\Parameter instance.

Skipped filters do not replace authorization. Keep mandatory access restrictions in server-controlled query conditions or scopes.


Bridge Comparison

Feature Doctrine DBAL Doctrine ORM Illuminate
SQL operators Engine-dependent Compatible subset† Engine-dependent
Date operators Engine-dependent ❌ Engine-dependent
Regex operators Engine-dependent ❌ Engine-dependent
Bitwise operators Engine-dependent ❌ Engine-dependent
ilike: / notilike: Engine-dependent ❌ Engine-dependent
FROM inference from paths ✅ ❌‡ ✅
JOIN from path segments ✅ ✅ (ORM assoc) ✅
EXISTS paths ✅ ✅ (SIZE/EXISTS DQL) ✅
Aggregate subqueries ✅ ✅ ✅

SQL support depends on the configured template and functions available in the selected database. A bridge does not make every operator portable across all engines.

† Compatible operators only — DQL-incompatible operators throw UnsupportedOperatorException.
‡ ORM bridge requires an explicit FROM / from() call before applying conditions.

On this page

Last updated on 30/10/2026 by Anonymous