+21
src/Contracts/Transformer.php
+21
src/Contracts/Transformer.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\Schema\Contracts;
4
+
5
+
interface Transformer
6
+
{
7
+
/**
8
+
* Transform raw data to model.
9
+
*/
10
+
public function fromArray(array $data): mixed;
11
+
12
+
/**
13
+
* Transform model to raw data.
14
+
*/
15
+
public function toArray(mixed $model): array;
16
+
17
+
/**
18
+
* Check if this transformer supports the given type.
19
+
*/
20
+
public function supports(string $type): bool;
21
+
}
+192
src/Services/ModelMapper.php
+192
src/Services/ModelMapper.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\Schema\Services;
4
+
5
+
use SocialDept\Schema\Contracts\Transformer;
6
+
use SocialDept\Schema\Exceptions\SchemaException;
7
+
8
+
class ModelMapper
9
+
{
10
+
/**
11
+
* Registered transformers.
12
+
*
13
+
* @var array<string, Transformer>
14
+
*/
15
+
protected array $transformers = [];
16
+
17
+
/**
18
+
* Register a transformer for a specific type.
19
+
*/
20
+
public function register(string $type, Transformer $transformer): self
21
+
{
22
+
$this->transformers[$type] = $transformer;
23
+
24
+
return $this;
25
+
}
26
+
27
+
/**
28
+
* Register multiple transformers at once.
29
+
*
30
+
* @param array<string, Transformer> $transformers
31
+
*/
32
+
public function registerMany(array $transformers): self
33
+
{
34
+
foreach ($transformers as $type => $transformer) {
35
+
$this->register($type, $transformer);
36
+
}
37
+
38
+
return $this;
39
+
}
40
+
41
+
/**
42
+
* Transform raw data to model.
43
+
*/
44
+
public function fromArray(string $type, array $data): mixed
45
+
{
46
+
$transformer = $this->getTransformer($type);
47
+
48
+
if ($transformer === null) {
49
+
throw SchemaException::withContext(
50
+
"No transformer registered for type '{$type}'",
51
+
['type' => $type]
52
+
);
53
+
}
54
+
55
+
return $transformer->fromArray($data);
56
+
}
57
+
58
+
/**
59
+
* Transform model to raw data.
60
+
*/
61
+
public function toArray(string $type, mixed $model): array
62
+
{
63
+
$transformer = $this->getTransformer($type);
64
+
65
+
if ($transformer === null) {
66
+
throw SchemaException::withContext(
67
+
"No transformer registered for type '{$type}'",
68
+
['type' => $type]
69
+
);
70
+
}
71
+
72
+
return $transformer->toArray($model);
73
+
}
74
+
75
+
/**
76
+
* Transform multiple items from arrays.
77
+
*
78
+
* @param array<array> $items
79
+
* @return array<mixed>
80
+
*/
81
+
public function fromArrayMany(string $type, array $items): array
82
+
{
83
+
return array_map(
84
+
fn (array $item) => $this->fromArray($type, $item),
85
+
$items
86
+
);
87
+
}
88
+
89
+
/**
90
+
* Transform multiple items to arrays.
91
+
*
92
+
* @param array<mixed> $items
93
+
* @return array<array>
94
+
*/
95
+
public function toArrayMany(string $type, array $items): array
96
+
{
97
+
return array_map(
98
+
fn (mixed $item) => $this->toArray($type, $item),
99
+
$items
100
+
);
101
+
}
102
+
103
+
/**
104
+
* Get transformer for a specific type.
105
+
*/
106
+
public function getTransformer(string $type): ?Transformer
107
+
{
108
+
// Check exact match first
109
+
if (isset($this->transformers[$type])) {
110
+
return $this->transformers[$type];
111
+
}
112
+
113
+
// Check if any transformer supports this type
114
+
foreach ($this->transformers as $transformer) {
115
+
if ($transformer->supports($type)) {
116
+
return $transformer;
117
+
}
118
+
}
119
+
120
+
return null;
121
+
}
122
+
123
+
/**
124
+
* Check if a transformer is registered for a type.
125
+
*/
126
+
public function has(string $type): bool
127
+
{
128
+
return $this->getTransformer($type) !== null;
129
+
}
130
+
131
+
/**
132
+
* Unregister a transformer.
133
+
*/
134
+
public function unregister(string $type): self
135
+
{
136
+
unset($this->transformers[$type]);
137
+
138
+
return $this;
139
+
}
140
+
141
+
/**
142
+
* Get all registered transformers.
143
+
*
144
+
* @return array<string, Transformer>
145
+
*/
146
+
public function all(): array
147
+
{
148
+
return $this->transformers;
149
+
}
150
+
151
+
/**
152
+
* Clear all registered transformers.
153
+
*/
154
+
public function clear(): self
155
+
{
156
+
$this->transformers = [];
157
+
158
+
return $this;
159
+
}
160
+
161
+
/**
162
+
* Try to transform from array, return null if transformer not found.
163
+
*/
164
+
public function tryFromArray(string $type, array $data): mixed
165
+
{
166
+
if (! $this->has($type)) {
167
+
return null;
168
+
}
169
+
170
+
return $this->fromArray($type, $data);
171
+
}
172
+
173
+
/**
174
+
* Try to transform to array, return null if transformer not found.
175
+
*/
176
+
public function tryToArray(string $type, mixed $model): ?array
177
+
{
178
+
if (! $this->has($type)) {
179
+
return null;
180
+
}
181
+
182
+
return $this->toArray($type, $model);
183
+
}
184
+
185
+
/**
186
+
* Get count of registered transformers.
187
+
*/
188
+
public function count(): int
189
+
{
190
+
return count($this->transformers);
191
+
}
192
+
}
+344
tests/Unit/Services/ModelMapperTest.php
+344
tests/Unit/Services/ModelMapperTest.php
···
1
+
<?php
2
+
3
+
namespace SocialDept\Schema\Tests\Unit\Services;
4
+
5
+
use Orchestra\Testbench\TestCase;
6
+
use SocialDept\Schema\Contracts\Transformer;
7
+
use SocialDept\Schema\Exceptions\SchemaException;
8
+
use SocialDept\Schema\Services\ModelMapper;
9
+
10
+
class ModelMapperTest extends TestCase
11
+
{
12
+
protected ModelMapper $mapper;
13
+
14
+
protected function setUp(): void
15
+
{
16
+
parent::setUp();
17
+
18
+
$this->mapper = new ModelMapper();
19
+
}
20
+
21
+
public function test_it_registers_transformer(): void
22
+
{
23
+
$transformer = $this->createTestTransformer('app.bsky.feed.post');
24
+
25
+
$this->mapper->register('app.bsky.feed.post', $transformer);
26
+
27
+
$this->assertTrue($this->mapper->has('app.bsky.feed.post'));
28
+
}
29
+
30
+
public function test_it_registers_multiple_transformers(): void
31
+
{
32
+
$transformer1 = $this->createTestTransformer('app.bsky.feed.post');
33
+
$transformer2 = $this->createTestTransformer('app.bsky.feed.repost');
34
+
35
+
$this->mapper->registerMany([
36
+
'app.bsky.feed.post' => $transformer1,
37
+
'app.bsky.feed.repost' => $transformer2,
38
+
]);
39
+
40
+
$this->assertTrue($this->mapper->has('app.bsky.feed.post'));
41
+
$this->assertTrue($this->mapper->has('app.bsky.feed.repost'));
42
+
}
43
+
44
+
public function test_it_transforms_from_array(): void
45
+
{
46
+
$transformer = $this->createTestTransformer('app.bsky.feed.post');
47
+
$this->mapper->register('app.bsky.feed.post', $transformer);
48
+
49
+
$data = ['text' => 'Hello, world!'];
50
+
$model = $this->mapper->fromArray('app.bsky.feed.post', $data);
51
+
52
+
$this->assertInstanceOf(\stdClass::class, $model);
53
+
$this->assertEquals('Hello, world!', $model->text);
54
+
}
55
+
56
+
public function test_it_transforms_to_array(): void
57
+
{
58
+
$transformer = $this->createTestTransformer('app.bsky.feed.post');
59
+
$this->mapper->register('app.bsky.feed.post', $transformer);
60
+
61
+
$model = (object) ['text' => 'Hello, world!'];
62
+
$data = $this->mapper->toArray('app.bsky.feed.post', $model);
63
+
64
+
$this->assertEquals(['text' => 'Hello, world!'], $data);
65
+
}
66
+
67
+
public function test_it_throws_exception_when_transformer_not_found_for_from_array(): void
68
+
{
69
+
$this->expectException(SchemaException::class);
70
+
71
+
$this->mapper->fromArray('unknown.type', []);
72
+
}
73
+
74
+
public function test_it_throws_exception_when_transformer_not_found_for_to_array(): void
75
+
{
76
+
$this->expectException(SchemaException::class);
77
+
78
+
$this->mapper->toArray('unknown.type', new \stdClass());
79
+
}
80
+
81
+
public function test_it_transforms_multiple_items_from_arrays(): void
82
+
{
83
+
$transformer = $this->createTestTransformer('app.bsky.feed.post');
84
+
$this->mapper->register('app.bsky.feed.post', $transformer);
85
+
86
+
$items = [
87
+
['text' => 'First post'],
88
+
['text' => 'Second post'],
89
+
];
90
+
91
+
$models = $this->mapper->fromArrayMany('app.bsky.feed.post', $items);
92
+
93
+
$this->assertCount(2, $models);
94
+
$this->assertEquals('First post', $models[0]->text);
95
+
$this->assertEquals('Second post', $models[1]->text);
96
+
}
97
+
98
+
public function test_it_transforms_multiple_items_to_arrays(): void
99
+
{
100
+
$transformer = $this->createTestTransformer('app.bsky.feed.post');
101
+
$this->mapper->register('app.bsky.feed.post', $transformer);
102
+
103
+
$models = [
104
+
(object) ['text' => 'First post'],
105
+
(object) ['text' => 'Second post'],
106
+
];
107
+
108
+
$arrays = $this->mapper->toArrayMany('app.bsky.feed.post', $models);
109
+
110
+
$this->assertCount(2, $arrays);
111
+
$this->assertEquals(['text' => 'First post'], $arrays[0]);
112
+
$this->assertEquals(['text' => 'Second post'], $arrays[1]);
113
+
}
114
+
115
+
public function test_it_gets_transformer(): void
116
+
{
117
+
$transformer = $this->createTestTransformer('app.bsky.feed.post');
118
+
$this->mapper->register('app.bsky.feed.post', $transformer);
119
+
120
+
$retrieved = $this->mapper->getTransformer('app.bsky.feed.post');
121
+
122
+
$this->assertSame($transformer, $retrieved);
123
+
}
124
+
125
+
public function test_it_returns_null_for_missing_transformer(): void
126
+
{
127
+
$transformer = $this->mapper->getTransformer('unknown.type');
128
+
129
+
$this->assertNull($transformer);
130
+
}
131
+
132
+
public function test_it_checks_if_has_transformer(): void
133
+
{
134
+
$transformer = $this->createTestTransformer('app.bsky.feed.post');
135
+
$this->mapper->register('app.bsky.feed.post', $transformer);
136
+
137
+
$this->assertTrue($this->mapper->has('app.bsky.feed.post'));
138
+
$this->assertFalse($this->mapper->has('unknown.type'));
139
+
}
140
+
141
+
public function test_it_unregisters_transformer(): void
142
+
{
143
+
$transformer = $this->createTestTransformer('app.bsky.feed.post');
144
+
$this->mapper->register('app.bsky.feed.post', $transformer);
145
+
146
+
$this->assertTrue($this->mapper->has('app.bsky.feed.post'));
147
+
148
+
$this->mapper->unregister('app.bsky.feed.post');
149
+
150
+
$this->assertFalse($this->mapper->has('app.bsky.feed.post'));
151
+
}
152
+
153
+
public function test_it_gets_all_transformers(): void
154
+
{
155
+
$transformer1 = $this->createTestTransformer('app.bsky.feed.post');
156
+
$transformer2 = $this->createTestTransformer('app.bsky.feed.repost');
157
+
158
+
$this->mapper->registerMany([
159
+
'app.bsky.feed.post' => $transformer1,
160
+
'app.bsky.feed.repost' => $transformer2,
161
+
]);
162
+
163
+
$all = $this->mapper->all();
164
+
165
+
$this->assertCount(2, $all);
166
+
$this->assertArrayHasKey('app.bsky.feed.post', $all);
167
+
$this->assertArrayHasKey('app.bsky.feed.repost', $all);
168
+
}
169
+
170
+
public function test_it_clears_all_transformers(): void
171
+
{
172
+
$transformer = $this->createTestTransformer('app.bsky.feed.post');
173
+
$this->mapper->register('app.bsky.feed.post', $transformer);
174
+
175
+
$this->assertEquals(1, $this->mapper->count());
176
+
177
+
$this->mapper->clear();
178
+
179
+
$this->assertEquals(0, $this->mapper->count());
180
+
}
181
+
182
+
public function test_it_tries_from_array_with_missing_transformer(): void
183
+
{
184
+
$result = $this->mapper->tryFromArray('unknown.type', []);
185
+
186
+
$this->assertNull($result);
187
+
}
188
+
189
+
public function test_it_tries_from_array_with_existing_transformer(): void
190
+
{
191
+
$transformer = $this->createTestTransformer('app.bsky.feed.post');
192
+
$this->mapper->register('app.bsky.feed.post', $transformer);
193
+
194
+
$result = $this->mapper->tryFromArray('app.bsky.feed.post', ['text' => 'Hello']);
195
+
196
+
$this->assertNotNull($result);
197
+
$this->assertEquals('Hello', $result->text);
198
+
}
199
+
200
+
public function test_it_tries_to_array_with_missing_transformer(): void
201
+
{
202
+
$result = $this->mapper->tryToArray('unknown.type', new \stdClass());
203
+
204
+
$this->assertNull($result);
205
+
}
206
+
207
+
public function test_it_tries_to_array_with_existing_transformer(): void
208
+
{
209
+
$transformer = $this->createTestTransformer('app.bsky.feed.post');
210
+
$this->mapper->register('app.bsky.feed.post', $transformer);
211
+
212
+
$model = (object) ['text' => 'Hello'];
213
+
$result = $this->mapper->tryToArray('app.bsky.feed.post', $model);
214
+
215
+
$this->assertNotNull($result);
216
+
$this->assertEquals(['text' => 'Hello'], $result);
217
+
}
218
+
219
+
public function test_it_counts_transformers(): void
220
+
{
221
+
$this->assertEquals(0, $this->mapper->count());
222
+
223
+
$this->mapper->register('app.bsky.feed.post', $this->createTestTransformer('app.bsky.feed.post'));
224
+
225
+
$this->assertEquals(1, $this->mapper->count());
226
+
227
+
$this->mapper->register('app.bsky.feed.repost', $this->createTestTransformer('app.bsky.feed.repost'));
228
+
229
+
$this->assertEquals(2, $this->mapper->count());
230
+
}
231
+
232
+
public function test_it_uses_wildcard_transformer(): void
233
+
{
234
+
$transformer = $this->createWildcardTransformer('app.bsky.feed.*');
235
+
$this->mapper->register('app.bsky.feed.*', $transformer);
236
+
237
+
$this->assertTrue($this->mapper->has('app.bsky.feed.post'));
238
+
$this->assertTrue($this->mapper->has('app.bsky.feed.repost'));
239
+
$this->assertFalse($this->mapper->has('app.bsky.graph.follow'));
240
+
}
241
+
242
+
public function test_it_prefers_exact_match_over_wildcard(): void
243
+
{
244
+
$wildcardTransformer = $this->createWildcardTransformer('app.bsky.feed.*');
245
+
$exactTransformer = $this->createTestTransformer('app.bsky.feed.post');
246
+
247
+
$this->mapper->register('app.bsky.feed.*', $wildcardTransformer);
248
+
$this->mapper->register('app.bsky.feed.post', $exactTransformer);
249
+
250
+
$retrieved = $this->mapper->getTransformer('app.bsky.feed.post');
251
+
252
+
$this->assertSame($exactTransformer, $retrieved);
253
+
}
254
+
255
+
public function test_it_chains_register_calls(): void
256
+
{
257
+
$transformer1 = $this->createTestTransformer('type1');
258
+
$transformer2 = $this->createTestTransformer('type2');
259
+
260
+
$result = $this->mapper
261
+
->register('type1', $transformer1)
262
+
->register('type2', $transformer2);
263
+
264
+
$this->assertSame($this->mapper, $result);
265
+
$this->assertTrue($this->mapper->has('type1'));
266
+
$this->assertTrue($this->mapper->has('type2'));
267
+
}
268
+
269
+
public function test_it_chains_register_many_calls(): void
270
+
{
271
+
$result = $this->mapper->registerMany([
272
+
'type1' => $this->createTestTransformer('type1'),
273
+
'type2' => $this->createTestTransformer('type2'),
274
+
]);
275
+
276
+
$this->assertSame($this->mapper, $result);
277
+
}
278
+
279
+
public function test_it_chains_unregister_calls(): void
280
+
{
281
+
$this->mapper->register('type1', $this->createTestTransformer('type1'));
282
+
283
+
$result = $this->mapper->unregister('type1');
284
+
285
+
$this->assertSame($this->mapper, $result);
286
+
}
287
+
288
+
public function test_it_chains_clear_calls(): void
289
+
{
290
+
$result = $this->mapper->clear();
291
+
292
+
$this->assertSame($this->mapper, $result);
293
+
}
294
+
295
+
protected function createTestTransformer(string $type): Transformer
296
+
{
297
+
return new class ($type) implements Transformer {
298
+
public function __construct(protected string $type)
299
+
{
300
+
}
301
+
302
+
public function fromArray(array $data): mixed
303
+
{
304
+
return (object) $data;
305
+
}
306
+
307
+
public function toArray(mixed $model): array
308
+
{
309
+
return (array) $model;
310
+
}
311
+
312
+
public function supports(string $type): bool
313
+
{
314
+
return $type === $this->type;
315
+
}
316
+
};
317
+
}
318
+
319
+
protected function createWildcardTransformer(string $pattern): Transformer
320
+
{
321
+
return new class ($pattern) implements Transformer {
322
+
public function __construct(protected string $pattern)
323
+
{
324
+
}
325
+
326
+
public function fromArray(array $data): mixed
327
+
{
328
+
return (object) $data;
329
+
}
330
+
331
+
public function toArray(mixed $model): array
332
+
{
333
+
return (array) $model;
334
+
}
335
+
336
+
public function supports(string $type): bool
337
+
{
338
+
$regex = '/^'.str_replace('\\*', '.*', preg_quote($this->pattern, '/')).'$/';
339
+
340
+
return (bool) preg_match($regex, $type);
341
+
}
342
+
};
343
+
}
344
+
}