Skip to content

Commit 70677b0

Browse files
roxblnfkgithub-actions
andauthored
Support wrapWhere() from database 2.19 (#567)
Co-authored-by: github-actions <github-actions@users.noreply.github.com>
1 parent 42af3d2 commit 70677b0

8 files changed

Lines changed: 434 additions & 3 deletions

File tree

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
],
4040
"require": {
4141
"php": ">=8.1",
42-
"cycle/database": "^2.17.0",
42+
"cycle/database": "^2.19.0",
4343
"doctrine/instantiator": "^1.3.1 || ^2.0",
4444
"spiral/core": "^2.8 || ^3.0"
4545
},

src/Select/QueryBuilder.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* @method QueryBuilder where(...$args);
1717
* @method QueryBuilder andWhere(...$args);
1818
* @method QueryBuilder orWhere(...$args);
19+
* @method QueryBuilder wrapWhere()
1920
* @method QueryBuilder having(...$args);
2021
* @method QueryBuilder andHaving(...$args);
2122
* @method QueryBuilder orHaving(...$args);
@@ -203,6 +204,9 @@ private function targetFunc(string $call): callable
203204
case 'andwhere':
204205
$call = 'and' . \ucfirst($this->forward);
205206
break;
207+
case 'wrapwhere':
208+
$call = 'wrap' . \ucfirst($this->forward);
209+
break;
206210
}
207211
}
208212

src/Select/ScopeInterface.php

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,98 @@
55
namespace Cycle\ORM\Select;
66

77
/**
8-
* Provides the ability to modify the selector and/or entity loader. Can be used to implement multi-table inheritance.
8+
* Scopes attach extra query criteria to every `Select` for a given entity — typical
9+
* use cases are soft-delete filtering, tenant isolation, default ordering, or
10+
* inheritance discriminators.
11+
*
12+
* A scope is invoked once per query build, **after** any user-supplied WHERE
13+
* conditions have already been registered on the underlying SelectQuery. That
14+
* timing is important: see "Avoiding scope bypass via orWhere" below.
15+
*
16+
* The {@see apply()} method receives a {@see QueryBuilder} that proxies the
17+
* underlying query. For scopes attached to joined loaders the builder forwards
18+
* `where*` and `wrapWhere()` calls to JOIN ON tokens (`onWhere`/`wrapOnWhere`)
19+
* rather than the top-level WHERE — the recommended pattern below applies
20+
* uniformly in both cases.
21+
*
22+
* ## Registering a scope
23+
*
24+
* Either via schema (applied automatically by `getRepository()`):
25+
*
26+
* Schema::SCOPE => SoftDeleteScope::class,
27+
*
28+
* Or per-query on a `Select`:
29+
*
30+
* $select->scope(new SoftDeleteScope());
31+
*
32+
* ## Recommended pattern: always call `wrapWhere()` first
33+
*
34+
* Start `apply()` with a {@see \Cycle\Database\Query\Traits\WhereTrait::wrapWhere()}
35+
* call, then add your conditions. This protects the scope from being bypassed by
36+
* user-supplied `orWhere` (explained below):
37+
*
38+
* final class SoftDeleteScope implements ScopeInterface
39+
* {
40+
* public function apply(QueryBuilder $query): void
41+
* {
42+
* $query->wrapWhere(); // enclose user wheres
43+
* $query->where('deleted_at', null); // scope condition outside
44+
* }
45+
* }
46+
*
47+
* `wrapWhere()` is a no-op when no WHERE tokens have been registered yet, so
48+
* it is always safe to call.
49+
*
50+
* ## Why `wrapWhere()` is needed
51+
*
52+
* A plain top-level WHERE added by a scope can be defeated by a user `orWhere`
53+
* because of SQL operator precedence — AND binds tighter than OR. Without
54+
* `wrapWhere()`, given a scope that simply does `$query->where('deleted_at', null)`
55+
* and a user query
56+
*
57+
* $select->scope(new SoftDeleteScope())
58+
* ->where('id', 1)
59+
* ->orWhere('id', 2);
60+
*
61+
* the resulting SQL is
62+
*
63+
* WHERE {id} = 1 OR {id} = 2 AND {deleted_at} IS NULL
64+
* ≡ WHERE {id} = 1 OR ({id} = 2 AND {deleted_at} IS NULL)
65+
*
66+
* which returns rows matching `id = 1` regardless of `deleted_at` — the scope
67+
* is silently bypassed on the first OR arm. With the recommended pattern above
68+
* the same query compiles to
69+
*
70+
* WHERE ({id} = 1 OR {id} = 2) AND {deleted_at} IS NULL
71+
*
72+
* and the scope holds regardless of how user code mixes AND/OR.
73+
*
74+
* ## Stacking multiple scopes
75+
*
76+
* Each scope in a chain can call `wrapWhere()` independently — every layer
77+
* encloses the previous accumulation, producing
78+
*
79+
* WHERE ((user_wheres) AND scope1_conds) AND scope2_conds
80+
*
81+
* which is logically equivalent to `user AND scope1 AND scope2`. Stack scopes
82+
* via a composite/aggregating scope or by registering them at the appropriate
83+
* layer of your application.
84+
*
85+
* ## Other usage
86+
*
87+
* Scopes are not limited to WHERE — `apply()` may also call `orderBy()`,
88+
* `having()`, `limit()`, etc. on the builder. Loader-aware scopes can use the
89+
* builder's `resolve()` to translate `relation.column` identifiers to proper
90+
* SQL aliases.
991
*/
1092
interface ScopeInterface
1193
{
1294
/**
13-
* Configure query and loader pair using proxy strategy.
95+
* Apply scope-specific modifications to the query builder.
96+
*
97+
* Called during query compilation, after any user-supplied WHERE conditions
98+
* have been registered on the underlying SelectQuery. See the interface-level
99+
* docblock for guidance on avoiding scope bypass via {@see QueryBuilder::wrapWhere()}.
14100
*/
15101
public function apply(QueryBuilder $query): void;
16102
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
// phpcs:ignoreFile
4+
declare(strict_types=1);
5+
6+
namespace Cycle\ORM\Tests\Fixtures;
7+
8+
use Cycle\ORM\Select\QueryBuilder;
9+
use Cycle\ORM\Select\ScopeInterface;
10+
11+
/**
12+
* Soft-delete scope that protects itself from user-added OR conditions by wrapping
13+
* already-registered WHERE tokens into a parenthesized group before adding its own
14+
* filter. With a plain {@see NotDeletedScope} a query like
15+
*
16+
* WHERE id = 1 OR id = 2 AND deleted_at IS NULL
17+
*
18+
* is parsed by SQL as
19+
*
20+
* WHERE id = 1 OR (id = 2 AND deleted_at IS NULL)
21+
*
22+
* which bypasses the scope on the first OR arm. With wrapWhere() the resulting
23+
* SQL becomes
24+
*
25+
* WHERE (id = 1 OR id = 2) AND deleted_at IS NULL
26+
*
27+
* which keeps the scope effective regardless of what user code added.
28+
*/
29+
class NotDeletedWrappedScope implements ScopeInterface
30+
{
31+
public function apply(QueryBuilder $query): void
32+
{
33+
$query->wrapWhere();
34+
$query->where('deleted_at', '=', null);
35+
}
36+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
// phpcs:ignoreFile
4+
declare(strict_types=1);
5+
6+
namespace Cycle\ORM\Tests\Fixtures;
7+
8+
use Cycle\ORM\Select\QueryBuilder;
9+
use Cycle\ORM\Select\ScopeInterface;
10+
11+
/**
12+
* Like {@see \Cycle\ORM\Select\QueryScope}, but calls wrapWhere() before adding its
13+
* conditions — protects the scope's WHERE/ON tokens from being bypassed by a later
14+
* `orWhere`/`orOnWhere`. For joined-loader scopes, wrapWhere() is forwarded to
15+
* wrapOnWhere() by {@see QueryBuilder::targetFunc()}.
16+
*/
17+
final class WrappedQueryScope implements ScopeInterface
18+
{
19+
public function __construct(
20+
private array $where,
21+
private array $orderBy = [],
22+
) {}
23+
24+
public function apply(QueryBuilder $query): void
25+
{
26+
$query->wrapWhere();
27+
$query->where($this->where)->orderBy($this->orderBy);
28+
}
29+
}

tests/ORM/Functional/Driver/Common/Mapper/SoftDeletesTest.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Cycle\ORM\Select;
1010
use Cycle\ORM\Tests\Functional\Driver\Common\BaseTest;
1111
use Cycle\ORM\Tests\Fixtures\NotDeletedScope;
12+
use Cycle\ORM\Tests\Fixtures\NotDeletedWrappedScope;
1213
use Cycle\ORM\Tests\Fixtures\SoftDeletedMapper;
1314
use Cycle\ORM\Tests\Fixtures\User;
1415
use Cycle\ORM\Tests\Traits\TableTrait;
@@ -63,6 +64,71 @@ public function testDelete(): void
6364
$this->assertNotNull($s->fetchOne());
6465
}
6566

67+
/**
68+
* Demonstrates the historic problem: a plain scope that adds a top-level
69+
* WHERE is bypassed by a user-added orWhere due to AND-over-OR precedence.
70+
*/
71+
public function testScopeBypassedByOrWhereWithoutWrapWhere(): void
72+
{
73+
$this->seedAliceDeletedAndBobAlive();
74+
75+
// No wrapWhere here — the scope contributes a top-level AND that ORs reach over.
76+
$orm = $this->orm->withHeap(new Heap());
77+
$rows = (new Select($orm, User::class))
78+
->scope(new NotDeletedScope())
79+
->where('id', 1)
80+
->orWhere('id', 2)
81+
->fetchAll();
82+
83+
// SQL: WHERE {id} = ? OR {id} = ? AND {deleted_at} IS NULL
84+
// ≡ WHERE {id} = 1 OR ({id} = 2 AND {deleted_at} IS NULL)
85+
// Alice (id=1) is returned despite being soft-deleted — scope is bypassed.
86+
$this->assertCount(2, $rows);
87+
$emails = \array_map(static fn($u) => $u->email, $rows);
88+
\sort($emails);
89+
$this->assertSame(['alice@test.com', 'bob@test.com'], $emails);
90+
}
91+
92+
/**
93+
* Same query as above but the scope calls wrapWhere() before adding its
94+
* condition. The user OR is enclosed in a group, the scope stays effective.
95+
*/
96+
public function testScopeProtectedByWrapWhereAgainstOrWhere(): void
97+
{
98+
$this->seedAliceDeletedAndBobAlive();
99+
100+
$orm = $this->orm->withHeap(new Heap());
101+
$rows = (new Select($orm, User::class))
102+
->scope(new NotDeletedWrappedScope())
103+
->where('id', 1)
104+
->orWhere('id', 2)
105+
->fetchAll();
106+
107+
// SQL: WHERE ({id} = ? OR {id} = ?) AND {deleted_at} IS NULL
108+
// Only Bob (id=2, alive) survives the scope.
109+
$this->assertCount(1, $rows);
110+
$this->assertSame('bob@test.com', $rows[0]->email);
111+
}
112+
113+
/**
114+
* Wrap-aware scope must remain a no-op when the user supplies no WHERE at all —
115+
* wrapWhere() short-circuits on empty token state, scope's own condition is the
116+
* only thing left.
117+
*/
118+
public function testWrappedScopeWithNoUserWhere(): void
119+
{
120+
$this->seedAliceDeletedAndBobAlive();
121+
122+
$orm = $this->orm->withHeap(new Heap());
123+
$rows = (new Select($orm, User::class))
124+
->scope(new NotDeletedWrappedScope())
125+
->fetchAll();
126+
127+
// SQL: WHERE {deleted_at} IS NULL
128+
$this->assertCount(1, $rows);
129+
$this->assertSame('bob@test.com', $rows[0]->email);
130+
}
131+
66132
public function setUp(): void
67133
{
68134
parent::setUp();
@@ -93,4 +159,22 @@ public function setUp(): void
93159
],
94160
]));
95161
}
162+
163+
private function seedAliceDeletedAndBobAlive(): void
164+
{
165+
$alice = new User();
166+
$alice->email = 'alice@test.com';
167+
$alice->balance = 100;
168+
169+
$bob = new User();
170+
$bob->email = 'bob@test.com';
171+
$bob->balance = 200;
172+
173+
(new Transaction($this->orm))->persist($alice)->persist($bob)->run();
174+
175+
// Soft-delete Alice; Bob stays alive.
176+
$orm = $this->orm->withHeap(new Heap());
177+
$alice = (new Select($orm, User::class))->wherePK(1)->fetchOne();
178+
(new Transaction($orm))->delete($alice)->run();
179+
}
96180
}

0 commit comments

Comments
 (0)