Skip to content

Commit 1e72aa0

Browse files
committed
Feature: Regular Expression Extractors to populate model properties from an arbitrary string.
1 parent 851c908 commit 1e72aa0

File tree

8 files changed

+205
-7
lines changed

8 files changed

+205
-7
lines changed

docs/VOM.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ The Versatile Object Mapper - or in short VOM - is a PHP library to transform an
5050
+ [Booleans](#booleans)
5151
+ [DateTime](#datetime)
5252
* [Value Map](#value-map)
53+
* [Regular Expression Extractors](#regular-expression-extractors)
5354
- [Interfaces and Abstract Classes](#interfaces-and-abstract-classes)
5455
- [Context](#context)
5556
* [Skip Null Values](#skip-null-values)
@@ -1229,6 +1230,58 @@ $object = $objectMapper->denormalize(['color' => 'RAINBOW'], ValueMap::class);
12291230
// $object->color is '#000000', the default value
12301231
```
12311232

1233+
## Regular Expression Extractors
1234+
1235+
If the data is a string consisting of multiple values, you can use regexp extractors to read the properties into your model.
1236+
1237+
On the `VOM\Model` attribute the regexp extractor must have named subpattern matching the model's properties.
1238+
1239+
```php
1240+
use Zolex\VOM\Mapping as VOM;
1241+
1242+
#[VOM\Model(extractor: '/^(?<filename>.+),tag:(?<tag>.*),description:(?<text>.*)/')]
1243+
class RegexpExtractorModel
1244+
{
1245+
#[VOM\Property]
1246+
public string $filename;
1247+
1248+
#[VOM\Property]
1249+
public string $tag;
1250+
1251+
#[VOM\Property]
1252+
public string $text;
1253+
}
1254+
```
1255+
1256+
```php
1257+
$model = $objectMapper->denormalize('image1.jpg,tag:foobar,description:source data is shit', RegexpExtractorModel::class);
1258+
```
1259+
1260+
Onb the `VOM\Property``attribute the regexp extractor must have exactly one subpattern to extract the data.
1261+
1262+
```php
1263+
use Zolex\VOM\Mapping as VOM;
1264+
1265+
#[VOM\Model]
1266+
class RegexpExtractorProperty
1267+
{
1268+
#[VOM\Property(extractor: '/^([^,]+)/')]
1269+
public string $filename;
1270+
1271+
#[VOM\Property(extractor: '/tag:([^,]+)/')]
1272+
public string $tag;
1273+
1274+
#[VOM\Property(
1275+
map: ['visible' => true, 'hidden' => false],
1276+
extractor: '/visibility:(visible|hidden)/'
1277+
)]
1278+
public bool $isVisible;
1279+
```
1280+
1281+
```php
1282+
$model = $objectMapper->denormalize('image1.jpg,tag:foobar,visibility:hidden', RegexpExtractorProperty::class);
1283+
```
1284+
12321285
## Interfaces and Abstract Classes
12331286

12341287
When dealing with objects that are fairly similar or share properties, you can use interfaces or abstract classes.

src/Mapping/AbstractProperty.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public function __construct(
2626
private ?string $dateTimeFormat = null,
2727
private ?array $map = null,
2828
private bool $serialized = false,
29+
private ?string $extractor = null,
2930
) {
3031
}
3132

@@ -93,4 +94,9 @@ public function isSerialized(): bool
9394
{
9495
return $this->serialized;
9596
}
97+
98+
public function getExtractor(): ?string
99+
{
100+
return $this->extractor;
101+
}
96102
}

src/Mapping/Model.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@
1616
#[\Attribute(\Attribute::TARGET_CLASS)]
1717
final class Model
1818
{
19-
public function __construct(private readonly ?array $factory = null)
19+
public function __construct(private readonly ?array $factory = null, private readonly ?string $extractor = null)
2020
{
2121
}
2222

2323
public function getFactory(): ?array
2424
{
2525
return $this->factory;
2626
}
27+
28+
public function getExtractor(): ?string
29+
{
30+
return $this->extractor;
31+
}
2732
}

src/Metadata/PropertyMetadata.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,11 @@ public function isSerialized(): bool
144144
return $this->attribute->isSerialized();
145145
}
146146

147+
public function getExtractor(): ?string
148+
{
149+
return $this->attribute->getExtractor();
150+
}
151+
147152
public function getMappedValue(mixed $value): mixed
148153
{
149154
$map = $this->attribute->getMap();

src/Serializer/Normalizer/ObjectNormalizer.php

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
141141
$model = $this->createInstance($data, $type, $context, $format);
142142
$metadata = $this->modelMetadataFactory->getMetadataFor($model::class);
143143
$allowedAttributes = $this->getAllowedAttributes($type, $context, true);
144+
145+
if (\is_string($data) && ($extractor = $metadata->getAttribute()?->getExtractor())) {
146+
if (!preg_match($extractor, $data, $matches)) {
147+
throw new MappingException(\sprintf('Extractor "%s" on model "%s" does not match the data "%s"', $extractor, $type, $data));
148+
}
149+
150+
$data = $matches;
151+
}
152+
144153
foreach ($metadata->getDenormalizers() as $denormalizer) {
145154
$attribute = $denormalizer->getPropertyName();
146155
if ($allowedAttributes && !\in_array($attribute, $allowedAttributes)) {
@@ -287,14 +296,23 @@ private function denormalizeProperty(string $type, mixed $data, PropertyMetadata
287296
$data = &$context[self::ROOT_DATA];
288297
}
289298

290-
if (\is_string($data) && $property->isSerialized()) {
291-
return $data;
292-
}
299+
if (\is_string($data)) {
300+
if ($property->isSerialized()) {
301+
return $data;
302+
}
293303

294-
if ($accessor = $property->getAccessor()) {
295-
$value = $this->propertyAccessor->getValue($data, $accessor);
304+
if ($extractor = $property->getExtractor()) {
305+
if (!preg_match($extractor, $data, $matches)) {
306+
throw new MappingException(\sprintf('Extractor "%s" on "%s::$%s" does not match the data "%s"', $extractor, $type, $property->getName(), $data));
307+
}
308+
$value = $matches[1] ?? null;
309+
}
296310
} else {
297-
$value = $data;
311+
if ($accessor = $property->getAccessor()) {
312+
$value = $this->propertyAccessor->getValue($data, $accessor);
313+
} else {
314+
$value = $data;
315+
}
298316
}
299317

300318
if ($property->hasMap()) {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the VOM package.
7+
*
8+
* (c) Andreas Linden <zlx@gmx.de>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Zolex\VOM\Test\Fixtures;
15+
16+
use Zolex\VOM\Mapping as VOM;
17+
18+
#[VOM\Model(extractor: '/^(?<filename>.+),tag:(?<tag>.*),visibility:(?<isVisible>visible|hidden)/')]
19+
class RegexpExtractorModel
20+
{
21+
#[VOM\Property]
22+
public string $filename;
23+
#[VOM\Property]
24+
public string $tag;
25+
#[VOM\Property(map: ['visible' => true, 'hidden' => false])]
26+
public bool $isVisible;
27+
28+
#[VOM\Normalizer]
29+
public function __toString(): string
30+
{
31+
return $this->filename.',tag:'.$this->tag.',visibility:'.($this->isVisible ? 'visible' : 'hidden');
32+
}
33+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the VOM package.
7+
*
8+
* (c) Andreas Linden <zlx@gmx.de>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Zolex\VOM\Test\Fixtures;
15+
16+
use Zolex\VOM\Mapping as VOM;
17+
18+
#[VOM\Model]
19+
class RegexpExtractorProperty
20+
{
21+
#[VOM\Property(extractor: '/^([^,]+)/')]
22+
public string $filename;
23+
#[VOM\Property(extractor: '/tag:([^,]+)/')]
24+
public string $tag;
25+
#[VOM\Property(map: ['visible' => true, 'hidden' => false], extractor: '/visibility:(visible|hidden)/')]
26+
public bool $isVisible;
27+
28+
#[VOM\Normalizer]
29+
public function __toString(): string
30+
{
31+
return $this->filename.',tag:'.$this->tag.',visibility:'.($this->isVisible ? 'visible' : 'hidden');
32+
}
33+
}

tests/Serializer/VersatileObjectMapperTest.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
use Zolex\VOM\Test\Fixtures\PrivateDenormalizer;
6767
use Zolex\VOM\Test\Fixtures\PrivateNormalizer;
6868
use Zolex\VOM\Test\Fixtures\PropertyPromotion;
69+
use Zolex\VOM\Test\Fixtures\RegexpExtractorModel;
70+
use Zolex\VOM\Test\Fixtures\RegexpExtractorProperty;
6971
use Zolex\VOM\Test\Fixtures\SerializedObject;
7072
use Zolex\VOM\Test\Fixtures\SerializedObjectArray;
7173
use Zolex\VOM\Test\Fixtures\SerializedObjectWithAdditionalNormalizer;
@@ -1362,4 +1364,47 @@ public function testSerializedObjectWithAdditionalNormalizerThrowsException(): v
13621364
$this->expectExceptionMessage('The "__toString()" method on model "Zolex\VOM\Test\Fixtures\SerializedObjectWithAdditionalNormalizer" is configured as a normalizer. There must be no additional normalizer methods.');
13631365
self::$serializer->denormalize([], SerializedObjectWithAdditionalNormalizer::class);
13641366
}
1367+
1368+
public function testRegexpExtractorModel(): void
1369+
{
1370+
$data = 'image1.jpg,tag:foobar,visibility:hidden';
1371+
$model = self::$serializer->denormalize($data, RegexpExtractorModel::class);
1372+
1373+
$this->assertInstanceOf(RegexpExtractorModel::class, $model);
1374+
$this->assertEquals('image1.jpg', $model->filename);
1375+
$this->assertEquals('foobar', $model->tag);
1376+
$this->assertFalse($model->isVisible);
1377+
1378+
$normalized = self::$serializer->normalize($model);
1379+
$this->assertEquals($data, $normalized);
1380+
}
1381+
1382+
public function testRegexpExtractorProperty(): void
1383+
{
1384+
$data = 'image2.jpg,tag:foobar,visibility:visible';
1385+
$model = self::$serializer->denormalize($data, RegexpExtractorProperty::class);
1386+
1387+
$this->assertInstanceOf(RegexpExtractorProperty::class, $model);
1388+
$this->assertEquals('image2.jpg', $model->filename);
1389+
$this->assertEquals('foobar', $model->tag);
1390+
$this->assertTrue($model->isVisible);
1391+
1392+
$normalized = self::$serializer->normalize($model);
1393+
$this->assertEquals($data, $normalized);
1394+
}
1395+
1396+
public function testRegexpExtractorPropertyThrowsExceptionWhenNotMatching(): void
1397+
{
1398+
$this->expectException(MappingException::class);
1399+
$this->expectExceptionMessage('Extractor "/tag:([^,]+)/" on "Zolex\VOM\Test\Fixtures\RegexpExtractorProperty::$tag" does not match the data "WRONGDATA"');
1400+
1401+
self::$serializer->denormalize('WRONGDATA', RegexpExtractorProperty::class);
1402+
}
1403+
1404+
public function testRegexpExtractorModelThrowsExceptionWhenNotMatching(): void
1405+
{
1406+
$this->expectException(MappingException::class);
1407+
$this->expectExceptionMessage('Extractor "/^(?<filename>.+),tag:(?<tag>.*),visibility:(?<isVisible>visible|hidden)/" on model "Zolex\VOM\Test\Fixtures\RegexpExtractorModel" does not match the data "WRONGDATA"');
1408+
self::$serializer->denormalize('WRONGDATA', RegexpExtractorModel::class);
1409+
}
13651410
}

0 commit comments

Comments
 (0)