PostQuery::getPostData()   A
last analyzed

Complexity

Conditions 5
Paths 10

Size

Total Lines 37

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 37
rs 9.0168
c 0
b 0
f 0
cc 5
nc 10
nop 1
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;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this; (Acme\App\Core\Component\...ion\Query\DQL\PostQuery) is incompatible with the return type declared by the interface Acme\App\Core\Component\...yInterface::includeTags of type self.

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:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
111
    }
112
113
    public function includeComments(): PostQueryInterface
114
    {
115
        $this->includeComments = true;
116
117
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this; (Acme\App\Core\Component\...ion\Query\DQL\PostQuery) is incompatible with the return type declared by the interface Acme\App\Core\Component\...erface::includeComments of type self.

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:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
118
    }
119
120
    public function includeCommentsAuthor(): PostQueryInterface
121
    {
122
        $this->includeCommentsAuthor = true;
123
124
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this; (Acme\App\Core\Component\...ion\Query\DQL\PostQuery) is incompatible with the return type declared by the interface Acme\App\Core\Component\...::includeCommentsAuthor of type self.

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:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
125
    }
126
127
    public function includeAuthor(): PostQueryInterface
128
    {
129
        $this->includeAuthor = true;
130
131
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this; (Acme\App\Core\Component\...ion\Query\DQL\PostQuery) is incompatible with the return type declared by the interface Acme\App\Core\Component\...nterface::includeAuthor of type self.

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:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
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);
0 ignored issues
show
Bug introduced by
It seems like $id defined by parameter $id on line 150 can also be of type string; however, Acme\App\Core\Component\...uery::findUsingPostId() does only seem to accept object<Acme\App\Core\Com...log\Domain\Post\PostId>, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
156
            case CommentId::class:
157
                return $this->findUsingCommentId($id);
0 ignored issues
show
Bug introduced by
It seems like $id defined by parameter $id on line 150 can also be of type string; however, Acme\App\Core\Component\...y::findUsingCommentId() does only seem to accept object<Acme\App\Core\Sha...Post\Comment\CommentId>, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
158
            case 'string':
159
                return $this->findUsingSlug($id);
0 ignored issues
show
Bug introduced by
It seems like $id defined by parameter $id on line 150 can also be of type object<Acme\App\Core\Com...log\Domain\Post\PostId> or object<Acme\App\Core\Sha...Post\Comment\CommentId>; however, Acme\App\Core\Component\...tQuery::findUsingSlug() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
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