|
5 | 5 | namespace Cycle\ORM\Select; |
6 | 6 |
|
7 | 7 | /** |
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. |
9 | 91 | */ |
10 | 92 | interface ScopeInterface |
11 | 93 | { |
12 | 94 | /** |
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()}. |
14 | 100 | */ |
15 | 101 | public function apply(QueryBuilder $query): void; |
16 | 102 | } |
0 commit comments