Skip to content

Commit 76bc70c

Browse files
apply better proposal merge
1 parent 9f9c9a6 commit 76bc70c

7 files changed

Lines changed: 415 additions & 41 deletions

File tree

README.md

Lines changed: 134 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ decorator that makes it easier to build your query in shared contexts.
3232
- [Examples](#examples)
3333
- [A real world case](#a-real-world-case)
3434
- [That's why SharedQueryBuilder is going to save your ass in these situations](#thats-why-sharedquerybuilder-is-going-to-save-your-ass-in-these-situations)
35+
- [Evolution: filters that receive and return a Proposal](#evolution-filters-that-receive-and-return-a-proposal)
3536
- [Immutable Parameters](#immutable-parameters)
3637
- [Set parameter and use it in expression at the same moment](#set-parameter-and-use-it-in-expression-at-the-same-moment)
3738
- [Unique parameters](#unique-parameters)
@@ -200,20 +201,32 @@ $proposal = $sqb->createEmptyProposal();
200201

201202
#### Collect API (no side effects until use)
202203

203-
A proposal exposes the same method names as the SQB for building a **local** set of conditions, joins, parameters, and other parts. Nothing is written to the main query until the proposal is used in an expression.
204+
A proposal exposes the same method names as the SQB for building a **local** set of conditions, joins, parameters, and other parts. Nothing is written to the main query until the proposal is used in an expression. Build conditions with the proposal’s `expr()` (e.g. `eq`, `neq`, `andX`, `orX`) so they stay object-oriented. **Never hardcode entity aliases**—use the SQB’s `withAlias(Entity::class, 'property')` (or the proposal’s, which delegates to the SQB) so the library resolves the correct alias.
204205

205206
```php
206207
$proposal = $sqb->createEmptyProposal('status_filter');
207208

208-
// Conditions
209-
$proposal->andWhere('u.status = ' . $proposal->withUniqueImmutableParameter('status', 'active'));
210-
$proposal->orWhere('u.role = ' . $proposal->withUniqueImmutableParameter('role', 'admin'));
209+
// Conditions: use withAlias() so the SQB resolves the entity alias (e.g. User → 'u')
210+
$proposal->andWhere(
211+
$proposal->expr()->eq(
212+
$proposal->withAlias(User::class, 'status'),
213+
$proposal->withUniqueImmutableParameter(':status', 'active')
214+
)
215+
);
216+
$proposal->orWhere(
217+
$proposal->expr()->eq(
218+
$proposal->withAlias(User::class, 'role'),
219+
$proposal->withUniqueImmutableParameter(':role', 'admin')
220+
)
221+
);
211222

212-
// Joins (added to the main SQB only when the proposal is expanded)
213-
$proposal->innerJoin('u.profile', 'p');
223+
// Joins: use withAlias() for the association path
224+
$proposal->innerJoin($proposal->withAlias(User::class, 'profile'), 'p');
214225

215-
// Optional: select, groupBy, orderBy, having
216-
$proposal->addSelect('u.id')->addGroupBy('u.id')->addOrderBy('u.createdAt', 'DESC');
226+
// Optional: select, groupBy, orderBy, having (withAlias for each path)
227+
$proposal->addSelect($proposal->withAlias(User::class, 'id'))
228+
->addGroupBy($proposal->withAlias(User::class, 'id'))
229+
->addOrderBy($proposal->withAlias(User::class, 'createdAt'), 'DESC');
217230
```
218231

219232
- **Parameters**: use only `withUniqueImmutableParameter` on the proposal; on expansion, parameter names are made unique on the main SQB and the condition DQL is updated accordingly.
@@ -227,25 +240,37 @@ To merge a proposal into the main query, pass it to `andWhere`, `orWhere`, `wher
227240
$sqb->select('u')->from(User::class, 'u');
228241

229242
$statusProposal = $sqb->createEmptyProposal('status');
230-
$statusProposal->andWhere('u.status = ' . $statusProposal->withUniqueImmutableParameter('s', 'active'));
243+
$statusProposal->andWhere(
244+
$statusProposal->expr()->eq(
245+
$statusProposal->withAlias(User::class, 'status'),
246+
$statusProposal->withUniqueImmutableParameter(':status', 'active')
247+
)
248+
);
231249

232250
$sqb->andWhere($statusProposal);
233251
// Now the main query has the proposal’s condition and parameter; its joins/select/etc. would be applied too if we had added any.
234252
```
235253

236-
You can combine multiple proposals in an OR (or AND) by expanding them first and passing the resulting strings to `expr()->orX()` (or `expr()->andX()`):
254+
You can combine multiple proposals in an OR (or AND) by passing the proposals directly to `expr()->orX()` (or `expr()->andX()`). The SQB expands them when building the where clause; you do not need to call `expandInto()`.
237255

238256
```php
239257
$proposal1 = $sqb->createEmptyProposal('p1');
240-
$proposal1->andWhere('u.role = ' . $proposal1->withUniqueImmutableParameter('r', 'admin'));
258+
$proposal1->andWhere(
259+
$proposal1->expr()->eq(
260+
$proposal1->withAlias(User::class, 'role'),
261+
$proposal1->withUniqueImmutableParameter(':role', 'admin')
262+
)
263+
);
241264

242265
$proposal2 = $sqb->createEmptyProposal('p2');
243-
$proposal2->andWhere('u.role = ' . $proposal2->withUniqueImmutableParameter('r', 'editor'));
266+
$proposal2->andWhere(
267+
$proposal2->expr()->eq(
268+
$proposal2->withAlias(User::class, 'role'),
269+
$proposal2->withUniqueImmutableParameter(':role', 'editor')
270+
)
271+
);
244272

245-
$sqb->andWhere($sqb->expr()->orX(
246-
$proposal1->expandInto($sqb),
247-
$proposal2->expandInto($sqb)
248-
));
273+
$sqb->andWhere($sqb->expr()->orX($proposal1, $proposal2));
249274
// Main query has (condition1 OR condition2) and both parameters.
250275
```
251276

@@ -258,30 +283,45 @@ After a proposal is expanded for the first time, it is marked **consumed**. Usin
258283
- **Introspection**: `hasConditions()`, `hasJoins()`, `hasParameters()`, `isEmpty()`, `isConsumed()`.
259284
- **Clear**: `clearWhere()`, `clearJoins()`, `clearParameters()`, `clearSelect()`, `clearGroupBy()`, `clearOrderBy()`, `clearHaving()`, `clearAll()`.
260285

261-
#### Example: filter as a proposal
286+
#### Example: filter receives a proposal, fills it, returns it; merge at upper level
262287

263-
A filter class can build a proposal and the controller merges it in one place:
288+
The **caller** (e.g. controller) creates an empty proposal and passes it to the filter. The filter **receives** the request and that proposal; it fills the proposal using the proposal’s methods (e.g. `withAlias(Entity::class, 'property')`, which delegates to the SQB) so aliases are never hardcoded, then **returns** the same proposal. The caller is responsible for merging the proposal into the query. That way the filter only builds its block of logic; where and how it is combined (e.g. `andWhere` vs `orWhere`) stays at the upper level.
264289

265290
```php
266291
// StatusFilter.php
292+
use Andante\Doctrine\ORM\SharedQueryBuilder\Proposal;
293+
use Symfony\Component\HttpFoundation\Request;
294+
267295
class StatusFilter implements FilterInterface
268296
{
269-
public function apply(SharedQueryBuilder $sqb, Request $request): void
297+
public function buildProposal(Request $request, Proposal $proposal): Proposal
270298
{
271299
$status = $request->query->get('status');
272300
if ($status === null) {
273-
return;
301+
return $proposal;
274302
}
275-
$proposal = $sqb->createEmptyProposal('status_filter');
276303
$proposal->andWhere(
277-
'u.status = ' . $proposal->withUniqueImmutableParameter('status', $status)
304+
$proposal->expr()->eq(
305+
$proposal->withAlias(User::class, 'status'),
306+
$proposal->withUniqueImmutableParameter(':status', $status)
307+
)
278308
);
279-
$sqb->andWhere($proposal);
309+
return $proposal;
280310
}
281311
}
282312
```
283313

284-
This keeps the filter responsible only for its own conditions and parameters, and avoids alias or parameter name clashes with other filters.
314+
```php
315+
// UserController.php (upper level: create proposal, pass to filter, merge here)
316+
$statusFilter = new StatusFilter();
317+
$proposal = $sqb->createEmptyProposal('status_filter');
318+
$statusFilter->buildProposal($request, $proposal);
319+
if ($proposal->hasConditions()) {
320+
$sqb->andWhere($proposal);
321+
}
322+
```
323+
324+
This keeps the filter responsible only for building its conditions and parameters (using `withAlias` so the library resolves entity aliases); the caller decides how to merge and avoids alias or parameter name clashes between filters.
285325

286326
## Examples
287327

@@ -293,11 +333,11 @@ There is no need to perform any join until we decide to use that filter. We can
293333
```php
294334
$sqb = SharedQueryBuilder::wrap($userRepository->createQueryBuilder('u'));
295335
$sqb
296-
->lazyJoin('u.address', 'a')
297-
->lazyJoin('a.building', 'b')
298-
//Let's add a WHERE condition that do not need our lazy joins
336+
->lazyJoin($sqb->withAlias(User::class, 'address'), 'a')
337+
->lazyJoin($sqb->withAlias(Address::class, 'building'), 'b')
338+
// Let's add a WHERE condition that do not need our lazy joins
299339
->andWhere(
300-
$sqb->expr()->eq('u.verifiedEmail', ':verified_email')
340+
$sqb->expr()->eq($sqb->withAlias(User::class, 'verifiedEmail'), ':verified_email')
301341
)
302342
->setParameter('verified_email', true)
303343
;
@@ -312,7 +352,7 @@ $users = $sqb->getQuery()->getResult();
312352
$buildingNameFilter = 'Building A';
313353
$sqb
314354
->andWhere(
315-
$sqb->expr()->eq('b.name', ':name_value')
355+
$sqb->expr()->eq($sqb->withAlias(Building::class, 'name'), ':name_value')
316356
)
317357
->setParameter('name_value', $buildingNameFilter)
318358
;
@@ -457,9 +497,9 @@ class UserController extends Controller
457497
$sqb = SharedQueryBuilder::wrap($userRepository->createQueryBuilder('u'));
458498
$sqb
459499
// Please note: Sure, you can mix "normal" join methods and "lazy" join methods
460-
->lazyJoin('u.address', 'a')
461-
->lazyJoin('a.building', 'b')
462-
->andWhere($sqb->expr()->eq('u.verifiedEmail', ':verified_email'))
500+
->lazyJoin($sqb->withAlias(User::class, 'address'), 'a')
501+
->lazyJoin($sqb->withAlias(Address::class, 'building'), 'b')
502+
->andWhere($sqb->expr()->eq($sqb->withAlias(User::class, 'verifiedEmail'), ':verified_email'))
463503
->setImmutableParameter('verified_email', true);
464504

465505
// Now Apply some optional filters from Request
@@ -503,6 +543,68 @@ class BuildingNameFilter implements FilterInterface
503543
}
504544
```
505545

546+
- 👍 No extra join statements executed when there is no need for them;
547+
548+
#### Evolution: filters that receive and return a Proposal
549+
550+
You can go one step further: have each filter **receive an empty Proposal** (created by the caller), fill it using `withAlias()` and the proposal’s `expr()`, and **return** that proposal. The controller then merges each proposal (e.g. with `andWhere`) at the upper level. That keeps the same benefits (no hardcoded aliases, merge logic in one place) and makes each filter a pure “block builder” that never touches the SQB’s where clause directly.
551+
552+
**Step 1: BuildingNameFilter receives and returns a Proposal**
553+
554+
```php
555+
// BuildingNameFilter.php
556+
use Andante\Doctrine\ORM\SharedQueryBuilder\Proposal;
557+
use Symfony\Component\HttpFoundation\Request;
558+
559+
class BuildingNameFilter implements FilterInterface
560+
{
561+
public function buildProposal(Request $request, Proposal $proposal): Proposal
562+
{
563+
$buildingName = $request->query->get('building-name');
564+
if ($buildingName === null || $buildingName === '' || !$proposal->hasEntity(Building::class)) {
565+
return $proposal;
566+
}
567+
$proposal->andWhere(
568+
$proposal->expr()->eq(
569+
$proposal->withAlias(Building::class, 'name'),
570+
$proposal->withUniqueImmutableParameter(':building_name', $buildingName)
571+
)
572+
);
573+
return $proposal;
574+
}
575+
}
576+
```
577+
578+
**Step 2: Controller creates proposals, passes them to both filters, merges at upper level**
579+
580+
```php
581+
// UserController.php
582+
use Andante\Doctrine\ORM\SharedQueryBuilder;
583+
use Andante\Doctrine\ORM\SharedQueryBuilder\Proposal;
584+
585+
$sqb = SharedQueryBuilder::wrap($userRepository->createQueryBuilder('u'));
586+
$sqb
587+
->lazyJoin($sqb->withAlias(User::class, 'address'), 'a')
588+
->lazyJoin($sqb->withAlias(Address::class, 'building'), 'b')
589+
->andWhere($sqb->expr()->eq($sqb->withAlias(User::class, 'verifiedEmail'), ':verified_email'))
590+
->setImmutableParameter('verified_email', true);
591+
592+
// Each filter receives an empty proposal, fills it, returns it; we merge here
593+
$statusProposal = $sqb->createEmptyProposal('status_filter');
594+
(new StatusFilter())->buildProposal($request, $statusProposal);
595+
if ($statusProposal->hasConditions()) {
596+
$sqb->andWhere($statusProposal);
597+
}
598+
599+
$buildingNameProposal = $sqb->createEmptyProposal('building_name_filter');
600+
(new BuildingNameFilter())->buildProposal($request, $buildingNameProposal);
601+
if ($buildingNameProposal->hasConditions()) {
602+
$sqb->andWhere($buildingNameProposal);
603+
}
604+
605+
$users = $sqb->unwrap()->getQuery()->getResult();
606+
```
607+
506608
- 👍 No extra join statements executed when there is no need for them;
507609
- 👍 No way to change/override parameters value once defined;
508610
- 👍 We can discover if the Query Builder is handling an Entity and then apply our business logic;

src/SharedQueryBuilder.php

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
use Andante\Doctrine\ORM\Exception\CannotOverrideParametersException;
99
use Andante\Doctrine\ORM\Exception\DqlErrorException;
1010
use Andante\Doctrine\ORM\Exception\LogicException;
11+
use Andante\Doctrine\ORM\SharedQueryBuilder\Expr as SQBExpr;
12+
use Andante\Doctrine\ORM\SharedQueryBuilder\Expression\Andx as ProposalAndx;
13+
use Andante\Doctrine\ORM\SharedQueryBuilder\Expression\Orx as ProposalOrx;
1114
use Andante\Doctrine\ORM\SharedQueryBuilder\Proposal;
1215
use Doctrine\Common\Collections\ArrayCollection;
1316
use Doctrine\Common\Collections\Criteria;
@@ -23,7 +26,7 @@
2326
* Doctrine QueryBuilder decorator for building queries in shared contexts:
2427
* entity alias resolution, lazy joins, and immutable/unique parameters.
2528
*
26-
* @method Expr expr()
29+
* @method SQBExpr expr()
2730
* @method static setCacheable(bool $cacheable)
2831
* @method bool isCacheable()
2932
* @method static setCacheRegion(string $cacheRegion)
@@ -179,6 +182,15 @@ public function createEmptyProposal(string $name = ''): Proposal
179182
return Proposal::from($this, $name);
180183
}
181184

185+
/**
186+
* Expression builder. orX() and andX() accept Proposal instances so you can pass proposals
187+
* directly without calling expandInto(); they are expanded when used in andWhere/orWhere.
188+
*/
189+
public function expr(): SQBExpr
190+
{
191+
return new SQBExpr($this->qb->expr());
192+
}
193+
182194
/**
183195
* @param string $join
184196
* @param string $alias
@@ -706,6 +718,9 @@ public function getImmutableParameters(): ArrayCollection
706718
*/
707719
public function __call(string $method, array $args)
708720
{
721+
if ($method === 'expr') {
722+
return $this->expr();
723+
}
709724
$whereHavingMethods = ['where', 'andWhere', 'orWhere', 'andHaving', 'orHaving'];
710725
if (\in_array($method, $whereHavingMethods, true)) {
711726
$args = \array_map(fn (mixed $arg): string => $this->expandExpressionTree($arg), $args);
@@ -734,6 +749,40 @@ private function expandExpressionTree(mixed $expr): string
734749
if ($expr instanceof Proposal) {
735750
return $expr->expandInto($this);
736751
}
752+
if ($expr instanceof ProposalOrx) {
753+
$parts = $expr->getParts();
754+
if (\count($parts) === 0) {
755+
return '1=1';
756+
}
757+
758+
return '(' . \implode(' OR ', \array_map(fn (mixed $p): string => $this->expandExpressionTree($p), $parts)) . ')';
759+
}
760+
if ($expr instanceof ProposalAndx) {
761+
$parts = $expr->getParts();
762+
if (\count($parts) === 0) {
763+
return '1=1';
764+
}
765+
766+
return '(' . \implode(' AND ', \array_map(fn (mixed $p): string => $this->expandExpressionTree($p), $parts)) . ')';
767+
}
768+
if ($expr instanceof Expr\Func) {
769+
$args = $expr->getArguments();
770+
$expanded = \array_map(fn (mixed $a): string => $this->expandExpressionTree($a), $args);
771+
772+
return $expr->getName() . '(' . \implode(', ', $expanded) . ')';
773+
}
774+
if ($expr instanceof Expr\Comparison) {
775+
$left = $this->expandExpressionTree($expr->getLeftExpr());
776+
$right = $this->expandExpressionTree($expr->getRightExpr());
777+
778+
return $left . ' ' . $expr->getOperator() . ' ' . $right;
779+
}
780+
if ($expr instanceof Expr\Math) {
781+
$left = $this->expandExpressionTree($expr->getLeftExpr());
782+
$right = $this->expandExpressionTree($expr->getRightExpr());
783+
784+
return $left . ' ' . $expr->getOperator() . ' ' . $right;
785+
}
737786
if ($expr instanceof Expr\Andx) {
738787
$parts = $expr->getParts();
739788
if (\count($parts) === 0) {

0 commit comments

Comments
 (0)