1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
declare(strict_types=1); |
4
|
|
|
|
5
|
|
|
/* |
6
|
|
|
* This file is part of the Explicit Architecture POC, |
7
|
|
|
* which is created on top of the Symfony Demo application. |
8
|
|
|
* |
9
|
|
|
* (c) Herberto Graça <[email protected]> |
10
|
|
|
* |
11
|
|
|
* For the full copyright and license information, please view the LICENSE |
12
|
|
|
* file that was distributed with this source code. |
13
|
|
|
*/ |
14
|
|
|
|
15
|
|
|
namespace Acme\App\Core\Component\Blog\Application\Query\DQL; |
16
|
|
|
|
17
|
|
|
use Acme\App\Core\Component\Blog\Application\Query\CommentListQueryInterface; |
18
|
|
|
use Acme\App\Core\Component\Blog\Application\Query\PostQueryInterface; |
19
|
|
|
use Acme\App\Core\Component\Blog\Application\Query\TagListQueryInterface; |
20
|
|
|
use Acme\App\Core\Component\Blog\Domain\Post\Post; |
21
|
|
|
use Acme\App\Core\Component\Blog\Domain\Post\PostId; |
22
|
|
|
use Acme\App\Core\Port\Persistence\DQL\DqlQueryBuilderInterface; |
23
|
|
|
use Acme\App\Core\Port\Persistence\QueryServiceRouterInterface; |
24
|
|
|
use Acme\App\Core\Port\Persistence\ResultCollection; |
25
|
|
|
use Acme\App\Core\Port\Persistence\ResultCollectionInterface; |
26
|
|
|
use Acme\App\Core\SharedKernel\Component\Blog\Domain\Post\Comment\CommentId; |
27
|
|
|
use Acme\PhpExtension\Exception\AcmeOverloadingException; |
28
|
|
|
use Acme\PhpExtension\Helper\TypeHelper; |
29
|
|
|
|
30
|
|
|
/** |
31
|
|
|
* Some times we have cases where we need very similar query objects, and end up with a set of query objects like: |
32
|
|
|
* - FindPostQuery |
33
|
|
|
* - FindPostWithAuthorQuery |
34
|
|
|
* - FindPostWithAuthorAndTagsQuery |
35
|
|
|
* - FindPostWithAuthorAndTagsAndCommentsQuery |
36
|
|
|
* - FindPostWithAuthorAndCommentsQuery |
37
|
|
|
* - FindPostWithTagsAndCommentsQuery |
38
|
|
|
* |
39
|
|
|
* Which is not rally nice. |
40
|
|
|
* |
41
|
|
|
* The solution is to create a Query object that we can tell what we actually need to get back each time we use it, |
42
|
|
|
* in the lines of a builder pattern, using fluent interfaces. |
43
|
|
|
* |
44
|
|
|
* This query object is quite flexible and it prevents us from having a set of very similar query objects. Although |
45
|
|
|
* with this flexibility it comes some added complexity I feel the balance is still positive, in this case. |
46
|
|
|
* |
47
|
|
|
* Furthermore, this is good for a generic use where the query doesn't return much data, in this case it returns 30 rows |
48
|
|
|
* at most (a page in the UI), so we can allow ourselves to get more data than needed in some use cases. However, if |
49
|
|
|
* it would return more than 500 rows I would prefer to make a specialized query returning only the columns needed for |
50
|
|
|
* that specific case. |
51
|
|
|
*/ |
52
|
|
|
final class PostQuery implements PostQueryInterface |
53
|
|
|
{ |
54
|
|
|
/** |
55
|
|
|
* @var bool |
56
|
|
|
*/ |
57
|
|
|
private $includeTags = false; |
58
|
|
|
|
59
|
|
|
/** |
60
|
|
|
* @var bool |
61
|
|
|
*/ |
62
|
|
|
private $includeComments = false; |
63
|
|
|
|
64
|
|
|
/** |
65
|
|
|
* @var bool |
66
|
|
|
*/ |
67
|
|
|
private $includeAuthor = false; |
68
|
|
|
|
69
|
|
|
/** |
70
|
|
|
* @var bool |
71
|
|
|
*/ |
72
|
|
|
private $includeCommentsAuthor = false; |
73
|
|
|
|
74
|
|
|
/** |
75
|
|
|
* @var DqlQueryBuilderInterface |
76
|
|
|
*/ |
77
|
|
|
private $dqlQueryBuilder; |
78
|
|
|
|
79
|
|
|
/** |
80
|
|
|
* @var QueryServiceRouterInterface |
81
|
|
|
*/ |
82
|
|
|
private $queryService; |
83
|
|
|
|
84
|
|
|
/** |
85
|
|
|
* @var TagListQueryInterface |
86
|
|
|
*/ |
87
|
|
|
private $tagListQuery; |
88
|
|
|
|
89
|
|
|
/** |
90
|
|
|
* @var CommentListQueryInterface |
91
|
|
|
*/ |
92
|
|
|
private $commentListQuery; |
93
|
|
|
|
94
|
|
|
public function __construct( |
95
|
|
|
DqlQueryBuilderInterface $dqlQueryBuilder, |
96
|
|
|
QueryServiceRouterInterface $queryService, |
97
|
|
|
TagListQueryInterface $tagListQuery, |
98
|
|
|
CommentListQueryInterface $commentListQuery |
99
|
|
|
) { |
100
|
|
|
$this->dqlQueryBuilder = $dqlQueryBuilder; |
101
|
|
|
$this->queryService = $queryService; |
102
|
|
|
$this->tagListQuery = $tagListQuery; |
103
|
|
|
$this->commentListQuery = $commentListQuery; |
104
|
|
|
} |
105
|
|
|
|
106
|
|
|
public function includeTags(): PostQueryInterface |
107
|
|
|
{ |
108
|
|
|
$this->includeTags = true; |
109
|
|
|
|
110
|
|
|
return $this; |
|
|
|
|
111
|
|
|
} |
112
|
|
|
|
113
|
|
|
public function includeComments(): PostQueryInterface |
114
|
|
|
{ |
115
|
|
|
$this->includeComments = true; |
116
|
|
|
|
117
|
|
|
return $this; |
|
|
|
|
118
|
|
|
} |
119
|
|
|
|
120
|
|
|
public function includeCommentsAuthor(): PostQueryInterface |
121
|
|
|
{ |
122
|
|
|
$this->includeCommentsAuthor = true; |
123
|
|
|
|
124
|
|
|
return $this; |
|
|
|
|
125
|
|
|
} |
126
|
|
|
|
127
|
|
|
public function includeAuthor(): PostQueryInterface |
128
|
|
|
{ |
129
|
|
|
$this->includeAuthor = true; |
130
|
|
|
|
131
|
|
|
return $this; |
|
|
|
|
132
|
|
|
} |
133
|
|
|
|
134
|
|
|
/** |
135
|
|
|
* Since this class only has one public method, it makes sense that it is designed as a callable, using the |
136
|
|
|
* magic method name `__invoke()`, instead of having a single public method called `execute()` which adds nothing |
137
|
|
|
* to code readability. |
138
|
|
|
* However, by using `__invoke()` we lose code completion, so in the end I prefer to use this `execute()` method. |
139
|
|
|
* |
140
|
|
|
* This method is also an example of how we can implement method overloading in PHP. |
141
|
|
|
* Method overloading is the capability of having one class with several methods with the same name, but different |
142
|
|
|
* parameters. |
143
|
|
|
* PHP does not offer method overloading because it offers default values for parameters, which makes it very tricky |
144
|
|
|
* to determine what method should actually be called for a set of arguments (should it use the method with exactly |
145
|
|
|
* that set of arguments, or the one with that set of arguments plus some other parameter with a default value?). |
146
|
|
|
* It's a poor mans overloading mechanism, but it's what we can do in PHP. |
147
|
|
|
* |
148
|
|
|
* @param PostId|CommentId|string $id |
149
|
|
|
*/ |
150
|
|
|
public function execute($id): ResultCollectionInterface |
151
|
|
|
{ |
152
|
|
|
$type = TypeHelper::getType($id); |
153
|
|
|
switch ($type) { |
154
|
|
|
case PostId::class: |
155
|
|
|
return $this->findUsingPostId($id); |
|
|
|
|
156
|
|
|
case CommentId::class: |
157
|
|
|
return $this->findUsingCommentId($id); |
|
|
|
|
158
|
|
|
case 'string': |
159
|
|
|
return $this->findUsingSlug($id); |
|
|
|
|
160
|
|
|
default: |
161
|
|
|
throw new AcmeOverloadingException( |
162
|
|
|
'Can handle arguments of types ' . PostId::class . ' and ' . CommentId::class . '.' |
163
|
|
|
. " Argument of type '$type' provided." |
164
|
|
|
); |
165
|
|
|
} |
166
|
|
|
} |
167
|
|
|
|
168
|
|
|
private function findUsingPostId(PostId $postId): ResultCollectionInterface |
169
|
|
|
{ |
170
|
|
|
return $this->getPostData(function (DqlQueryBuilderInterface $queryBuilder) use ($postId): void { |
171
|
|
|
$queryBuilder->where('Post.id = :postId') |
172
|
|
|
->setParameter('postId', $postId); |
173
|
|
|
}); |
174
|
|
|
} |
175
|
|
|
|
176
|
|
|
private function findUsingCommentId(CommentId $commentId): ResultCollectionInterface |
177
|
|
|
{ |
178
|
|
|
return $this->getPostData( |
179
|
|
|
function (DqlQueryBuilderInterface $queryBuilder) use ($commentId): void { |
180
|
|
|
$queryBuilder->join('Post.comments', 'Comments') |
181
|
|
|
->where('Comments = :commentId') |
182
|
|
|
->setParameter('commentId', $commentId); |
183
|
|
|
} |
184
|
|
|
); |
185
|
|
|
} |
186
|
|
|
|
187
|
|
|
private function findUsingSlug(string $slug): ResultCollectionInterface |
188
|
|
|
{ |
189
|
|
|
return $this->getPostData( |
190
|
|
|
function (DqlQueryBuilderInterface $queryBuilder) use ($slug): void { |
191
|
|
|
$queryBuilder->where('Post.slug = :slug') |
192
|
|
|
->setParameter('slug', $slug); |
193
|
|
|
} |
194
|
|
|
); |
195
|
|
|
} |
196
|
|
|
|
197
|
|
|
private function getPostData(callable $matchingFunction): ResultCollectionInterface |
198
|
|
|
{ |
199
|
|
|
$this->dqlQueryBuilder->create(Post::class, 'Post') |
200
|
|
|
->select( |
201
|
|
|
'Post.id', |
202
|
|
|
'Post.title', |
203
|
|
|
'Post.publishedAt', |
204
|
|
|
'Post.summary', |
205
|
|
|
'Post.content', |
206
|
|
|
'Post.slug' |
207
|
|
|
); |
208
|
|
|
|
209
|
|
|
$matchingFunction($this->dqlQueryBuilder); |
210
|
|
|
|
211
|
|
|
if ($this->includeAuthor) { |
212
|
|
|
$this->joinAuthor($this->dqlQueryBuilder); |
213
|
|
|
} |
214
|
|
|
|
215
|
|
|
$postData = $this->queryService->query($this->dqlQueryBuilder->build()) |
216
|
|
|
->getSingleResult(); |
217
|
|
|
|
218
|
|
|
if (empty($postData)) { |
219
|
|
|
return new ResultCollection(); |
220
|
|
|
} |
221
|
|
|
|
222
|
|
|
if ($this->includeTags) { |
223
|
|
|
$postData['tagList'] = $this->findTags($postData['id']); |
224
|
|
|
} |
225
|
|
|
|
226
|
|
|
if ($this->includeComments) { |
227
|
|
|
$postData['commentList'] = $this->findComments($postData['id']); |
228
|
|
|
} |
229
|
|
|
|
230
|
|
|
$this->resetJoins(); |
231
|
|
|
|
232
|
|
|
return new ResultCollection([$postData]); |
233
|
|
|
} |
234
|
|
|
|
235
|
|
|
private function joinAuthor(DqlQueryBuilderInterface $queryBuilder): void |
236
|
|
|
{ |
237
|
|
|
$queryBuilder->addSelect( |
238
|
|
|
'Author.id AS authorId', |
239
|
|
|
'Author.mobile AS authorMobile', |
240
|
|
|
'Author.fullName AS authorFullName', |
241
|
|
|
'Author.email AS authorEmail' |
242
|
|
|
) |
243
|
|
|
// This join with 'User:User' is the same as a join with User::class. The main difference is that this way |
244
|
|
|
// we are not depending directly on the User entity, but on a configurable alias. The advantage is that we |
245
|
|
|
// can change where the user data is stored and this query will remain the same. For example we could move |
246
|
|
|
// this component into a microservice, with its own curated user data, and we wouldn't need to change this |
247
|
|
|
// query, only the doctrine configuration. |
248
|
|
|
->join('User:User', 'Author', 'WITH', 'Author.id = Post.authorId'); |
249
|
|
|
} |
250
|
|
|
|
251
|
|
|
private function findTags(PostId $postId): array |
252
|
|
|
{ |
253
|
|
|
return $this->tagListQuery->execute($postId)->toArray(); |
254
|
|
|
} |
255
|
|
|
|
256
|
|
|
private function findComments(PostId $postId): array |
257
|
|
|
{ |
258
|
|
|
if ($this->includeCommentsAuthor) { |
259
|
|
|
$this->commentListQuery->includeAuthor(); |
260
|
|
|
} |
261
|
|
|
|
262
|
|
|
return $this->commentListQuery->execute($postId)->toArray(); |
263
|
|
|
} |
264
|
|
|
|
265
|
|
|
private function resetJoins(): void |
266
|
|
|
{ |
267
|
|
|
$this->includeTags = false; |
268
|
|
|
$this->includeAuthor = false; |
269
|
|
|
$this->includeComments = false; |
270
|
|
|
$this->includeCommentsAuthor = false; |
271
|
|
|
} |
272
|
|
|
} |
273
|
|
|
|
If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.
Let’s take a look at an example:
Our function
my_function
expects aPost
object, and outputs the author of the post. The base classPost
returns a simple string and outputting a simple string will work just fine. However, the child classBlogPost
which is a sub-type ofPost
instead decided to return anobject
, and is therefore violating the SOLID principles. If aBlogPost
were passed tomy_function
, PHP would not complain, but ultimately fail when executing thestrtoupper
call in its body.