Skip to content

Commit 84a96d8

Browse files
Add Proposal feature (2.1.0-beta.1)
- Proposal: temporary container for conditions, joins, params, select/groupBy/orderBy/having - Merge by using proposal in andWhere/orWhere/where/andHaving/orHaving - createEmptyProposal(), nested proposals, consumed state, introspection, clear/clone - README: document Proposals Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2b0bb10 commit 84a96d8

4 files changed

Lines changed: 1032 additions & 0 deletions

File tree

README.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ conventions.
3030
which alias is used for an entity when you are outside its creation context;
3131
- **Lazy joins** to declare join statements to be performed only if related criteria are defined;
3232
- **Immutable** and **unique** query **parameters**;
33+
- **Proposals**: collect conditions, joins, parameters (and select/groupBy/orderBy/having) in a temporary object and merge them into the main query by using the proposal in `andWhere` / `orWhere`—ideal for strategies or filters that need to contribute a whole “block” of DQL;
3334
- Works like magic ✨.
3435

3536
## Requirements
@@ -156,6 +157,107 @@ added to your DQL query only when you add **another condition/dql part** which r
156157
Based on how confused you are right now, you can check [why you should need this](#why-do-i-need-this)
157158
or [some examples](#examples) to achieve your "OMG" revelation moment.
158159

160+
### Proposals
161+
162+
When you split query building across multiple strategies or filter classes, you often want each one to contribute a **block** of logic: several conditions, joins, parameters, and maybe select/groupBy/orderBy/having. **Proposals** let you collect that block in a temporary object and “merge” it into the main `SharedQueryBuilder` in one go—by using the proposal inside `andWhere`, `orWhere`, `where`, `andHaving`, or `orHaving`. There is no separate `merge()` call: **merging happens when the proposal is used in one of those methods.**
163+
164+
#### Creating a proposal
165+
166+
Create an empty proposal from the `SharedQueryBuilder`; you can give it a name (useful for debugging) or leave it empty to get a unique auto-generated name.
167+
168+
```php
169+
// Named proposal
170+
$proposal = $sqb->createEmptyProposal('building_filter');
171+
172+
// Anonymous proposal (unique name generated automatically)
173+
$proposal = $sqb->createEmptyProposal();
174+
```
175+
176+
#### Collect API (no side effects until use)
177+
178+
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.
179+
180+
```php
181+
$proposal = $sqb->createEmptyProposal('status_filter');
182+
183+
// Conditions
184+
$proposal->andWhere('u.status = ' . $proposal->withUniqueImmutableParameter('status', 'active'));
185+
$proposal->orWhere('u.role = ' . $proposal->withUniqueImmutableParameter('role', 'admin'));
186+
187+
// Joins (added to the main SQB only when the proposal is expanded)
188+
$proposal->innerJoin('u.profile', 'p');
189+
190+
// Optional: select, groupBy, orderBy, having
191+
$proposal->addSelect('u.id')->addGroupBy('u.id')->addOrderBy('u.createdAt', 'DESC');
192+
```
193+
194+
- **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.
195+
- **Nested proposals**: you can add another proposal as a condition: `$proposal->andWhere($nestedProposal)`. When the parent is expanded, nested proposals are expanded recursively.
196+
197+
#### Merging by use
198+
199+
To merge a proposal into the main query, pass it to `andWhere`, `orWhere`, `where`, `andHaving`, or `orHaving`. The SQB will expand the proposal (apply its joins, parameters, select/groupBy/orderBy/having, build the condition, and replace the proposal with the resulting DQL).
200+
201+
```php
202+
$sqb->select('u')->from(User::class, 'u');
203+
204+
$statusProposal = $sqb->createEmptyProposal('status');
205+
$statusProposal->andWhere('u.status = ' . $statusProposal->withUniqueImmutableParameter('s', 'active'));
206+
207+
$sqb->andWhere($statusProposal);
208+
// Now the main query has the proposal’s condition and parameter; its joins/select/etc. would be applied too if we had added any.
209+
```
210+
211+
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()`):
212+
213+
```php
214+
$proposal1 = $sqb->createEmptyProposal('p1');
215+
$proposal1->andWhere('u.role = ' . $proposal1->withUniqueImmutableParameter('r', 'admin'));
216+
217+
$proposal2 = $sqb->createEmptyProposal('p2');
218+
$proposal2->andWhere('u.role = ' . $proposal2->withUniqueImmutableParameter('r', 'editor'));
219+
220+
$sqb->andWhere($sqb->expr()->orX(
221+
$proposal1->expandInto($sqb),
222+
$proposal2->expandInto($sqb)
223+
));
224+
// Main query has (condition1 OR condition2) and both parameters.
225+
```
226+
227+
#### Consumed state and reuse
228+
229+
After a proposal is expanded for the first time, it is marked **consumed**. Using the same proposal again in another `andWhere`/`orWhere` is a no-op: it expands to a neutral `1=1` so the query result is unchanged. Cloning a proposal gives a non-consumed copy with the same collected state.
230+
231+
#### Introspection and clear
232+
233+
- **Introspection**: `hasConditions()`, `hasJoins()`, `hasParameters()`, `isEmpty()`, `isConsumed()`.
234+
- **Clear**: `clearWhere()`, `clearJoins()`, `clearParameters()`, `clearSelect()`, `clearGroupBy()`, `clearOrderBy()`, `clearHaving()`, `clearAll()`.
235+
236+
#### Example: filter as a proposal
237+
238+
A filter class can build a proposal and the controller merges it in one place:
239+
240+
```php
241+
// StatusFilter.php
242+
class StatusFilter implements FilterInterface
243+
{
244+
public function apply(SharedQueryBuilder $sqb, Request $request): void
245+
{
246+
$status = $request->query->get('status');
247+
if ($status === null) {
248+
return;
249+
}
250+
$proposal = $sqb->createEmptyProposal('status_filter');
251+
$proposal->andWhere(
252+
'u.status = ' . $proposal->withUniqueImmutableParameter('status', $status)
253+
);
254+
$sqb->andWhere($proposal);
255+
}
256+
}
257+
```
258+
259+
This keeps the filter responsible only for its own conditions and parameters, and avoids alias or parameter name clashes with other filters.
260+
159261
## Examples
160262

161263
Let's suppose we need to list `User` entities but we also have an **optional filter** to search a user by their

src/SharedQueryBuilder.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
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\Proposal;
1112
use Doctrine\Common\Collections\ArrayCollection;
1213
use Doctrine\Common\Collections\Criteria;
1314
use Doctrine\DBAL\ArrayParameterType;
@@ -173,6 +174,11 @@ public function unwrap(): QueryBuilder
173174
return $this->qb;
174175
}
175176

177+
public function createEmptyProposal(string $name = ''): Proposal
178+
{
179+
return Proposal::from($this, $name);
180+
}
181+
176182
/**
177183
* @param string $join
178184
* @param string $alias
@@ -700,6 +706,11 @@ public function getImmutableParameters(): ArrayCollection
700706
*/
701707
public function __call(string $method, array $args)
702708
{
709+
$whereHavingMethods = ['where', 'andWhere', 'orWhere', 'andHaving', 'orHaving'];
710+
if (\in_array($method, $whereHavingMethods, true)) {
711+
$args = \array_map(fn (mixed $arg): string => $this->expandExpressionTree($arg), $args);
712+
}
713+
703714
$callable = [$this->qb, $method];
704715
if (\is_callable($callable)) {
705716
$returnObj = \call_user_func_array($callable, $args);
@@ -712,6 +723,45 @@ public function __call(string $method, array $args)
712723
throw new LogicException(sprintf('Undefined method - %s::%s', \get_class($this->qb), $method));
713724
}
714725

726+
/**
727+
* Recursively expand expression tree: replace Proposal instances with their DQL condition (and apply their joins/params to this SQB).
728+
*
729+
* @return string DQL condition fragment
730+
* @param mixed $expr
731+
*/
732+
private function expandExpressionTree(mixed $expr): string
733+
{
734+
if ($expr instanceof Proposal) {
735+
return $expr->expandInto($this);
736+
}
737+
if ($expr instanceof Expr\Andx) {
738+
$parts = $expr->getParts();
739+
if (\count($parts) === 0) {
740+
return '1=1';
741+
}
742+
743+
return '(' . \implode(' AND ', \array_map(fn (mixed $p): string => $this->expandExpressionTree($p), $parts)) . ')';
744+
}
745+
if ($expr instanceof Expr\Orx) {
746+
$parts = $expr->getParts();
747+
if (\count($parts) === 0) {
748+
return '1=1';
749+
}
750+
751+
return '(' . \implode(' OR ', \array_map(fn (mixed $p): string => $this->expandExpressionTree($p), $parts)) . ')';
752+
}
753+
if (\is_object($expr) && \method_exists($expr, '__toString')) {
754+
return (string) $expr;
755+
}
756+
if (\is_string($expr)) {
757+
return $expr;
758+
}
759+
760+
throw new LogicException(
761+
\sprintf('Cannot expand expression of type %s in %s.', \get_debug_type($expr), self::class)
762+
);
763+
}
764+
715765
public function __clone()
716766
{
717767
$this->qb = clone $this->qb;

0 commit comments

Comments
 (0)