• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

strictlyPHP / domantra / #23

18 Apr 2026 05:59AM UTC coverage: 97.256% (-0.3%) from 97.516%
#23

push

web-flow
Merge pull request #36 from strictlyPHP/feat/expand-by-default-flag

feat!: replace allowExpansion bool with ExpansionPolicy enum

10 of 10 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

319 of 328 relevant lines covered (97.26%)

4.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.77
/src/Query/QueryBus.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace StrictlyPHP\Domantra\Query;
6

7
use StrictlyPHP\Domantra\Domain\AbstractAggregateRoot;
8
use StrictlyPHP\Domantra\Query\Exception\ItemNotFoundException;
9
use StrictlyPHP\Domantra\Query\Exception\ItemNotFoundExceptionInterface;
10
use StrictlyPHP\Domantra\Query\Handlers\DtoHandlerHandlerInterface;
11
use StrictlyPHP\Domantra\Query\Handlers\PaginatedHandlerInterface;
12
use StrictlyPHP\Domantra\Query\Handlers\SingleHandlerInterface;
13
use StrictlyPHP\Domantra\Query\Response\ModelResponse;
14
use StrictlyPHP\Domantra\Query\Response\PaginatedModelResponse;
15
use StrictlyPHP\Domantra\Query\Response\ResponseInterface;
16

17
class QueryBus implements QueryBusInterface
18
{
19
    /**
20
     * @var array<class-string, SingleHandlerInterface|PaginatedHandlerInterface<mixed>>
21
     */
22
    private array $handlers = [];
23

24
    /**
25
     * @var array<class-string, ExpansionPolicy>
26
     */
27
    private array $expansionPolicy = [];
28

29
    public function __construct(
30
        private AggregateRootHandler $aggregateRootHandler,
31
        private CachedDtoHandler $cachedDtoHandler
32
    ) {
33
    }
26✔
34

35
    /**
36
     * @param class-string $queryClass
37
     */
38
    public function registerHandler(string $queryClass, SingleHandlerInterface|PaginatedHandlerInterface|DtoHandlerHandlerInterface $handler, ExpansionPolicy $expansionPolicy = ExpansionPolicy::Disabled): void
39
    {
40
        $this->handlers[$queryClass] = $handler;
24✔
41
        $this->expansionPolicy[$queryClass] = $expansionPolicy;
24✔
42
    }
43

44
    /**
45
     * @param list<string>|null $expand See {@see QueryBusInterface::handle()} for semantics.
46
     *
47
     * @throws ItemNotFoundException
48
     */
49
    public function handle(object $query, ?string $role = null, ?array $expand = null): ResponseInterface
50
    {
51
        $class = get_class($query);
25✔
52
        if (! isset($this->handlers[$class])) {
25✔
53
            throw new \RuntimeException("No handler registered for query: $class");
1✔
54
        }
55
        $handler = $this->handlers[$class];
24✔
56

57
        if ($handler instanceof SingleHandlerInterface) {
24✔
58
            if ($query instanceof \Stringable) {
22✔
59
                return new ModelResponse($this->expandDto($this->aggregateRootHandler->handle($query, $handler, $role), $role, $expand));
21✔
60
            } else {
61
                throw new \RuntimeException(sprintf('Query must implement %s when the return type is %s', \Stringable::class, AbstractAggregateRoot::class));
1✔
62
            }
63
        } elseif ($handler instanceof PaginatedHandlerInterface) {
2✔
64
            $paginatedCollection = $handler->__invoke($query);
2✔
65
            $items = [];
2✔
66
            foreach ($paginatedCollection as $id) {
2✔
67
                $idClass = get_class($id);
2✔
68
                /** @var SingleHandlerInterface $idHandler */
69
                $idHandler = $this->handlers[$idClass];
2✔
70
                $items[] = $this->expandDto(
2✔
71
                    $this->aggregateRootHandler->handle($id, $idHandler, $role),
2✔
72
                    $role,
2✔
73
                    $expand
2✔
74
                );
2✔
75
            }
76

77
            return new PaginatedModelResponse(
2✔
78
                $items,
2✔
79
                $paginatedCollection->getPage(),
2✔
80
                $paginatedCollection->getPerPage(),
2✔
81
                $paginatedCollection->getTotalItems()
2✔
82
            );
2✔
83
        } else {
84
            // We should never reach here. We are doing this to future-proof the code
85
            throw new \RuntimeException(sprintf('Handling failed. handler %s must be an instance of %s or %s', get_class($handler), SingleHandlerInterface::class, PaginatedHandlerInterface::class));
×
86
        }
87
    }
88

89
    /**
90
     * @param list<string>|null $expand See {@see QueryBusInterface::handle()} for semantics.
91
     */
92
    protected function expandDto(object $dto, ?string $role, ?array $expand = null): object
93
    {
94
        $expanded = (object) [];
24✔
95

96
        // Two passes so raw properties always win a name collision with a derived
97
        // expanded key, regardless of declaration order. A DTO with both `profileId`
98
        // and `profile` would otherwise see `profile` (expansion of `profileId`)
99
        // overwritten by the raw `profile` field when iterated after it.
100
        foreach (get_object_vars($dto) as $property => $value) {
24✔
101
            $expanded->$property = $value;
24✔
102
        }
103

104
        foreach (get_object_vars($dto) as $property => $value) {
24✔
105
            if (! is_object($value) || $property === 'id') {
24✔
106
                continue;
23✔
107
            }
108
            $class = get_class($value);
20✔
109
            if (! isset($this->handlers[$class])) {
20✔
UNCOV
110
                continue;
×
111
            }
112

113
            $policy = $this->expansionPolicy[$class] ?? ExpansionPolicy::Disabled;
20✔
114
            if ($policy === ExpansionPolicy::Disabled) {
20✔
115
                continue;
1✔
116
            }
117

118
            if ($expand === null) {
19✔
119
                if ($policy !== ExpansionPolicy::ByDefault) {
10✔
120
                    continue;
1✔
121
                }
122
            } elseif (! in_array($property, $expand, true)) {
9✔
123
                continue;
7✔
124
            }
125

126
            $expandedProperty = $this->getExpandedPropertyName($property);
14✔
127
            if (property_exists($expanded, $expandedProperty)) {
14✔
128
                continue;
1✔
129
            }
130

131
            $handler = $this->handlers[$class];
14✔
132
            try {
133
                if ($handler instanceof DtoHandlerHandlerInterface) {
14✔
134
                    $expandedValue = $this->cachedDtoHandler->handle($value, $handler, $role);
12✔
135
                } elseif ($handler instanceof SingleHandlerInterface) {
2✔
136
                    $expandedValue = $this->aggregateRootHandler->handle($value, $handler, $role);
1✔
137
                } else {
138
                    throw new \RuntimeException(sprintf('Handler %s must be an instance of %s or %s', $class, DtoHandlerHandlerInterface::class, SingleHandlerInterface::class));
11✔
139
                }
140
            } catch (ItemNotFoundExceptionInterface $e) {
4✔
141
                $expandedValue = null;
2✔
142
            }
143

144
            $expanded->$expandedProperty = $expandedValue;
12✔
145
        }
146

147
        return $expanded;
22✔
148
    }
149

150
    private function getExpandedPropertyName(string $property): string
151
    {
152
        if (str_ends_with($property, 'Id') && strlen($property) > 2) {
14✔
153
            return substr($property, 0, -2);
10✔
154
        }
155

156
        return $property . 'Expanded';
5✔
157
    }
158
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc