Skip to content

Commit e6f04ee

Browse files
authored
feat(mapper): map()->with()->to() (#951)
1 parent 64b1ff0 commit e6f04ee

File tree

6 files changed

+145
-52
lines changed

6 files changed

+145
-52
lines changed

src/Tempest/Framework/Testing/Http/HttpRouterTester.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ public function sendRequest(Request $request): TestResponseHelper
134134
$router = $this->container->get(Router::class);
135135

136136
return new TestResponseHelper(
137-
$router->dispatch(map($request)->with(RequestToPsrRequestMapper::class)),
137+
$router->dispatch(map($request)->with(RequestToPsrRequestMapper::class)->do()),
138138
);
139139
}
140140

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Tempest\Mapper\Exceptions;
4+
5+
use Exception;
6+
7+
final class MissingMapperException extends Exception
8+
{
9+
public function __construct()
10+
{
11+
parent::__construct("Cannot map using `do()` without calling `with()` first: `map()->with()->do()`");
12+
}
13+
}

src/Tempest/Mapper/src/ObjectFactory.php

Lines changed: 95 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
namespace Tempest\Mapper;
66

77
use Closure;
8-
use ReflectionException;
98
use Tempest\Container\Container;
109
use Tempest\Mapper\Exceptions\CannotMapDataException;
10+
use Tempest\Mapper\Exceptions\MissingMapperException;
1111
use Tempest\Mapper\Mappers\ArrayToJsonMapper;
1212
use Tempest\Mapper\Mappers\JsonToArrayMapper;
1313
use Tempest\Mapper\Mappers\ObjectToArrayMapper;
@@ -21,13 +21,14 @@ final class ObjectFactory
2121

2222
private mixed $to;
2323

24+
private array $with = [];
25+
2426
private bool $isCollection = false;
2527

2628
public function __construct(
2729
private readonly MapperConfig $config,
2830
private readonly Container $container,
29-
) {
30-
}
31+
) {}
3132

3233
/**
3334
* @template T of object
@@ -70,6 +71,18 @@ public function from(mixed $data): mixed
7071
);
7172
}
7273

74+
/**
75+
* @template MapperType of \Tempest\Mapper\Mapper
76+
* @param Closure(MapperType $mapper, mixed $from): mixed|class-string<\Tempest\Mapper\Mapper> ...$mappers
77+
* @return self<ClassType>
78+
*/
79+
public function with(Closure|string ...$mappers): self
80+
{
81+
$this->with = [...$this->with, ...$mappers];
82+
83+
return $this;
84+
}
85+
7386
/**
7487
* @template T of object
7588
* @param T|class-string<T>|string $to
@@ -84,33 +97,53 @@ public function to(mixed $to): mixed
8497
);
8598
}
8699

100+
public function do(): mixed
101+
{
102+
if ($this->with === []) {
103+
throw new MissingMapperException();
104+
}
105+
106+
$result = $this->from;
107+
108+
foreach ($this->with as $mapper) {
109+
$result = $this->mapWith(
110+
mapper: $mapper,
111+
from: $result,
112+
to: null,
113+
);
114+
}
115+
116+
return $result;
117+
}
118+
87119
public function toArray(): array
88120
{
89121
if (is_object($this->from)) {
90-
return $this->with(ObjectToArrayMapper::class);
122+
return $this->with(ObjectToArrayMapper::class)->do();
91123
}
124+
92125
if (is_array($this->from)) {
93126
return $this->from;
94127
}
128+
95129
if (is_string($this->from) && json_validate($this->from)) {
96-
return $this->with(JsonToArrayMapper::class);
97-
}
98-
else {
99-
throw new CannotMapDataException($this->from, 'array');
130+
return $this->with(JsonToArrayMapper::class)->do();
100131
}
132+
133+
throw new CannotMapDataException($this->from, 'array');
101134
}
102135

103136
public function toJson(): string
104137
{
105138
if (is_object($this->from)) {
106-
return $this->with(ObjectToJsonMapper::class);
139+
return $this->with(ObjectToJsonMapper::class)->do();
107140
}
141+
108142
if (is_array($this->from)) {
109-
return $this->with(ArrayToJsonMapper::class);
110-
}
111-
else {
112-
throw new CannotMapDataException($this->from, 'json');
143+
return $this->with(ArrayToJsonMapper::class)->do();
113144
}
145+
146+
throw new CannotMapDataException($this->from, 'json');
114147
}
115148

116149
/**
@@ -127,44 +160,13 @@ public function map(mixed $from, mixed $to): mixed
127160
);
128161
}
129162

130-
/**
131-
* @template MapperType of \Tempest\Mapper\Mapper
132-
* @param Closure(MapperType $mapper, mixed $from): mixed|class-string<\Tempest\Mapper\Mapper> ...$mappers
133-
* @throws ReflectionException
134-
*/
135-
public function with(Closure|string ...$mappers): mixed
136-
{
137-
$result = $this->from;
138-
139-
foreach ($mappers as $mapper) {
140-
if ($mapper instanceof Closure) {
141-
$function = new FunctionReflector($mapper);
142-
143-
$data = [
144-
'from' => $result,
145-
];
146-
147-
foreach ($function->getParameters() as $parameter) {
148-
$data[$parameter->getName()] ??= $this->container->get($parameter->getType()->getName());
149-
}
150-
151-
$result = $mapper(...$data);
152-
} else {
153-
$mapper = $this->container->get($mapper);
154-
155-
/** @var Mapper $mapper */
156-
$result = $mapper->map($result, null);
157-
}
158-
}
159-
160-
return $result;
161-
}
162-
163163
private function mapObject(
164164
mixed $from,
165165
mixed $to,
166166
bool $isCollection,
167-
): mixed {
167+
): mixed
168+
{
169+
// Map collections
168170
if ($isCollection && is_array($from)) {
169171
return array_map(
170172
fn (mixed $item) => $this->mapObject(
@@ -176,6 +178,22 @@ private function mapObject(
176178
);
177179
}
178180

181+
// Map using explicitly defined mappers
182+
if ($this->with) {
183+
$result = $from;
184+
185+
foreach ($this->with as $mapper) {
186+
$result = $this->mapWith(
187+
mapper: $mapper,
188+
from: $result,
189+
to: $to,
190+
);
191+
}
192+
193+
return $result;
194+
}
195+
196+
// Map using an inferred mapper
179197
$mappers = $this->config->mappers;
180198

181199
foreach ($mappers as $mapperClass) {
@@ -189,4 +207,34 @@ private function mapObject(
189207

190208
throw new CannotMapDataException($from, $to);
191209
}
210+
211+
/**
212+
* @template MapperType of \Tempest\Mapper\Mapper
213+
* @param Closure(MapperType $mapper, mixed $from): mixed|class-string<\Tempest\Mapper\Mapper> $mapper
214+
*/
215+
private function mapWith(
216+
mixed $mapper,
217+
mixed $from,
218+
mixed $to,
219+
): mixed
220+
{
221+
if ($mapper instanceof Closure) {
222+
$function = new FunctionReflector($mapper);
223+
224+
$data = [
225+
'from' => $from,
226+
];
227+
228+
foreach ($function->getParameters() as $parameter) {
229+
$data[$parameter->getName()] ??= $this->container->get($parameter->getType()->getName());
230+
}
231+
232+
return $mapper(...$data);
233+
}
234+
235+
$mapper = $this->container->get($mapper);
236+
237+
/** @var Mapper $mapper */
238+
return $mapper->map($from, $to);
239+
}
192240
}

src/Tempest/Router/src/GenericRouter.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public function __construct(
4242
public function dispatch(Request|PsrRequest $request): Response
4343
{
4444
if (! $request instanceof PsrRequest) {
45-
$request = map($request)->with(RequestToPsrRequestMapper::class);
45+
$request = map($request)->with(RequestToPsrRequestMapper::class)->do();
4646
}
4747

4848
$matchedRoute = $this->routeMatcher->match($request);
@@ -166,7 +166,7 @@ private function createResponse(Response|View $input): Response
166166
return $input;
167167
}
168168

169-
private function resolveRequest(PsrRequest $psrRequest, MatchedRoute $matchedRoute): Request
169+
private function resolveRequest(\Psr\Http\Message\ServerRequestInterface|\Tempest\Mapper\ObjectFactory $psrRequest, MatchedRoute $matchedRoute): Request
170170
{
171171
// Let's find out if our input request data matches what the route's action needs
172172
$requestClass = GenericRequest::class;

tests/Integration/Http/Responses/InvalidTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ final class InvalidTest extends FrameworkIntegrationTestCase
2323
public function test_invalid_with_psr_request(): void
2424
{
2525
/** @var PsrRequest $request */
26-
$request = map(new GenericRequest(Method::GET, '/original', ['foo' => 'bar']))->with(RequestToPsrRequestMapper::class);
26+
$request = map(new GenericRequest(Method::GET, '/original', ['foo' => 'bar']))->with(RequestToPsrRequestMapper::class)->do();
2727
$request = $request->withHeader('Referer', '/original');
2828

2929
$response = new Invalid(

tests/Integration/Mapper/ObjectFactoryTest.php

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Tests\Tempest\Integration\Mapper;
66

77
use Tempest\Mapper\Exceptions\CannotMapDataException;
8+
use Tempest\Mapper\Exceptions\MissingMapperException;
89
use Tempest\Mapper\Mappers\ArrayToJsonMapper;
910
use Tempest\Mapper\Mappers\ArrayToObjectMapper;
1011
use Tempest\Mapper\Mappers\ObjectToArrayMapper;
@@ -79,8 +80,39 @@ public function test_map_with(): void
7980
fn (ArrayToObjectMapper $mapper, mixed $from) => $mapper->map($from, ObjectA::class),
8081
ObjectToArrayMapper::class,
8182
ArrayToJsonMapper::class,
82-
);
83+
)->do();
8384

8485
$this->assertSame('{"a":"a","b":"b"}', $result);
8586
}
87+
88+
public function test_map_do_without_with_throws(): void
89+
{
90+
$this->expectException(MissingMapperException::class);
91+
92+
map([])->do();
93+
}
94+
95+
public function test_map_with_to(): void
96+
{
97+
$result = map(['a' => 'a', 'b' => 'b'])->with(ArrayToObjectMapper::class)->to(ObjectA::class);
98+
99+
$this->assertSame('a', $result->a);
100+
$this->assertSame('b', $result->b);
101+
}
102+
103+
public function test_map_with_collection_to(): void
104+
{
105+
$result = map([
106+
['a' => 'a', 'b' => 'b'],
107+
['a' => 'c', 'b' => 'd'],
108+
])
109+
->with(ArrayToObjectMapper::class)
110+
->collection()
111+
->to(ObjectA::class);
112+
113+
$this->assertSame('a', $result[0]->a);
114+
$this->assertSame('b', $result[0]->b);
115+
$this->assertSame('c', $result[1]->a);
116+
$this->assertSame('d', $result[1]->b);
117+
}
86118
}

0 commit comments

Comments
 (0)