Skip to content

Commit 7e369e7

Browse files
committed
Allow only one transforming filter per property
The transforming filter now isn't required to be the last applied filter in the filter chain of a single property Added advanced filter test cases
1 parent dc41c00 commit 7e369e7

File tree

8 files changed

+251
-50
lines changed

8 files changed

+251
-50
lines changed

docs/source/nonStandardExtensions/filter.rst

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Transforming filter
4747

4848
.. warning::
4949

50-
Read this section carefully if you want to use filters which transform the type of the property
50+
Read this section carefully and understand it if you want to use filters which transform the type of the property
5151

5252
Filters may change the type of the property. For example the builtin filter **dateTime** creates a DateTime object. Consequently further validations like pattern checks for the string property won't be performed.
5353

@@ -57,6 +57,8 @@ The return type of the last applied filter will be used to define the type of th
5757

5858
If you write a custom transforming filter you must define the return type of your filter function as the implementation uses Reflection methods to determine to which type a value is transformed by a filter.
5959

60+
Only one transforming filter per property is allowed. may be positioned anywhere in the filter chain of a single property. If multiple filters are applied and a transforming filter is among them you have to make sure the property types are compatible.
61+
6062
Builtin filter
6163
--------------
6264

@@ -157,14 +159,19 @@ Let's have a look how the generated model behaves:
157159
Additional options
158160
~~~~~~~~~~~~~~~~~~
159161

160-
================ ============= ===========
161-
Option Default value Description
162-
================ ============= ===========
163-
convertNullToNow false If null is provided a DateTime object with the current time will be created (works only if the property isn't required as null would be denied otherwise before the filter is executed)
164-
denyEmptyValue false An empty string value will be denied (by default an empty string value will result in a DateTime object with the current time)
165-
createFromFormat null Provide a pattern which is used to parse the provided value (DateTime object will be created via DateTime::createFromFormat if a format is provided)
166-
outputFormat DATE_ISO8601 The output format if serialization is enabled and toArray or toJSON is called on a transformed property. If a createFromFormat is defined but no outputFormat the createFromFormat value will override the default value
167-
================ ============= ===========
162+
================ ============= ===========
163+
Option Default value Description
164+
================ ============= ===========
165+
convertNullToNow false If null is provided a DateTime object with the current time will be created (works only if the property isn't required as null would be denied otherwise before the filter is executed)
166+
convertEmptyValueToNull false If an empty string is provided and this option is set to true the property will contain null after the filter has been applied
167+
denyEmptyValue false An empty string value will be denied (by default an empty string value will result in a DateTime object with the current time)
168+
createFromFormat null Provide a pattern which is used to parse the provided value (DateTime object will be created via DateTime::createFromFormat if a format is provided)
169+
outputFormat DATE_ISO8601 The output format if serialization is enabled and toArray or toJSON is called on a transformed property. If a createFromFormat is defined but no outputFormat the createFromFormat value will override the default value
170+
================ ============= ===========
171+
172+
.. hint::
173+
174+
If the dateTime filter is used without the createFromFormat option the string will be passed into the DateTime constructor. Consequently also strings like '+1 day' will be converted to the corresponding DateTime objects.
168175

169176
Custom filter
170177
-------------

src/Model/Validator/FilterValidator.php

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@
99
use PHPModelGenerator\Model\Property\PropertyInterface;
1010
use PHPModelGenerator\Model\Schema;
1111
use PHPModelGenerator\PropertyProcessor\Filter\FilterInterface;
12+
use PHPModelGenerator\PropertyProcessor\Filter\TransformingFilterInterface;
1213
use PHPModelGenerator\Utils\RenderHelper;
1314
use ReflectionException;
1415
use ReflectionMethod;
1516

17+
/**
18+
* Class FilterValidator
19+
*
20+
* @package PHPModelGenerator\Model\Validator
21+
*/
1622
class FilterValidator extends PropertyTemplateValidator
1723
{
1824
/**
@@ -48,18 +54,6 @@ public function __construct(
4854
);
4955
}
5056

51-
// check if the return type of the provided filter transforms the value. If the value is transformed by the
52-
// filter make sure the filter is only executed if a non-transformed value is provided.
53-
// This is required as a setter (eg. for a string property which is modified by the DateTime filter into a
54-
// DateTime object) also accepts a transformed value (in this case a DateTime object).
55-
$typeAfterFilter = (new ReflectionMethod($filter->getFilter()[0], $filter->getFilter()[1]))->getReturnType();
56-
if ($typeAfterFilter &&
57-
$typeAfterFilter->getName() &&
58-
!in_array($typeAfterFilter->getName(), $filter->getAcceptedTypes())
59-
) {
60-
$transformedCheck = (new ReflectionTypeCheckValidator($typeAfterFilter, $property, $schema))->getCheck();
61-
}
62-
6357
parent::__construct(
6458
sprintf(
6559
'Filter %s is not compatible with property type " . gettype($value) . " for property %s',
@@ -68,7 +62,7 @@ public function __construct(
6862
),
6963
DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'Filter.phptpl',
7064
[
71-
'skipTransformedValuesCheck' => $transformedCheck ?? '',
65+
'skipTransformedValuesCheck' => $this->getTransformedCheck($filter, $property, $schema),
7266
// check if the given value has a type matched by the filter
7367
'typeCheck' => !empty($filter->getAcceptedTypes())
7468
? '($value !== null && (!is_' .
@@ -78,9 +72,44 @@ public function __construct(
7872
'filterClass' => $filter->getFilter()[0],
7973
'filterMethod' => $filter->getFilter()[1],
8074
'filterOptions' => var_export($filterOptions, true),
81-
'transferExceptionMessage' => '{$e->getMessage()}',
75+
'transferExceptionMessage' => sprintf(
76+
'Invalid value for property %s denied by filter %s: {$e->getMessage()}',
77+
$property->getName(),
78+
$filter->getToken()
79+
),
8280
'viewHelper' => new RenderHelper($generatorConfiguration),
8381
]
8482
);
8583
}
84+
85+
/**
86+
* Check if the return type of the provided filter transforms the value. If the value is transformed by the filter
87+
* make sure the filter is only executed if a non-transformed value is provided.
88+
* This is required as a setter (eg. for a string property which is modified by the DateTime filter into a DateTime
89+
* object) also accepts a transformed value (in this case a DateTime object).
90+
*
91+
* @param FilterInterface $filter
92+
* @param PropertyInterface $property
93+
* @param Schema $schema
94+
*
95+
* @return string
96+
*
97+
* @throws ReflectionException
98+
*/
99+
private function getTransformedCheck(FilterInterface $filter, PropertyInterface $property, Schema $schema): string
100+
{
101+
if ($filter instanceof TransformingFilterInterface) {
102+
$typeAfterFilter = (new ReflectionMethod($filter->getFilter()[0], $filter->getFilter()[1]))
103+
->getReturnType();
104+
105+
if ($typeAfterFilter &&
106+
$typeAfterFilter->getName() &&
107+
!in_array($typeAfterFilter->getName(), $filter->getAcceptedTypes())
108+
) {
109+
return (new ReflectionTypeCheckValidator($typeAfterFilter, $property, $schema))->getCheck();
110+
}
111+
}
112+
113+
return '';
114+
}
86115
}

src/Model/Validator/ReflectionTypeCheckValidator.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ class ReflectionTypeCheckValidator extends PropertyValidator
2525
public function __construct(ReflectionType $reflectionType, PropertyInterface $property, Schema $schema)
2626
{
2727
if ($reflectionType->isBuiltin()) {
28-
$skipTransformedValuesCheck = "!is_{$reflectionType->getName()}(\$value)";
28+
$typeCheck = "!is_{$reflectionType->getName()}(\$value)";
2929
} else {
30-
$skipTransformedValuesCheck = "!(\$value instanceof {$reflectionType->getName()})";
30+
$typeCheck = "!(\$value instanceof {$reflectionType->getName()})";
3131
// make sure the returned class is imported so the instanceof check can be performed
3232
$schema->addUsedClass($reflectionType->getName());
3333
}
3434

3535
parent::__construct(
36-
$skipTransformedValuesCheck,
36+
$typeCheck,
3737
sprintf(
3838
'Invalid type for %s. Requires %s, got " . gettype($value) . "',
3939
$property->getName(),

src/PropertyProcessor/Filter/FilterProcessor.php

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public function process(
4444
$filterList = [$filterList];
4545
}
4646

47-
$filter = null;
47+
$hasTransformingFilter = false;
4848

4949
foreach ($filterList as $filterToken) {
5050
$filterOptions = [];
@@ -61,24 +61,31 @@ public function process(
6161
new FilterValidator($generatorConfiguration, $filter, $property, $schema, $filterOptions),
6262
3
6363
);
64-
}
6564

66-
// check if the last applied filter has changed the type of the property
67-
if ($filter instanceof TransformingFilterInterface) {
68-
$typeAfterFilter = (new ReflectionMethod($filter->getFilter()[0], $filter->getFilter()[1]))
69-
->getReturnType();
65+
if ($filter instanceof TransformingFilterInterface) {
66+
if ($hasTransformingFilter) {
67+
throw new SchemaException(
68+
"Applying multiple transforming filters for property {$property->getName()} is not supported"
69+
);
70+
}
71+
72+
$hasTransformingFilter = true;
73+
74+
$typeAfterFilter = (new ReflectionMethod($filter->getFilter()[0], $filter->getFilter()[1]))
75+
->getReturnType();
7076

71-
if ($typeAfterFilter &&
72-
$typeAfterFilter->getName() &&
73-
$property->getType() !== $typeAfterFilter->getName()
74-
) {
75-
$this->extendTypeCheckValidatorToAllowTransformedValue($property, $schema, $typeAfterFilter);
77+
if ($typeAfterFilter &&
78+
$typeAfterFilter->getName() &&
79+
$property->getType() !== $typeAfterFilter->getName()
80+
) {
81+
$this->extendTypeCheckValidatorToAllowTransformedValue($property, $schema, $typeAfterFilter);
7682

77-
$property->setType($property->getType(), $typeAfterFilter->getName());
83+
$property->setType($property->getType(), $typeAfterFilter->getName());
7884

79-
$schema->addCustomSerializer(
80-
new TransformingFilterSerializer($property->getAttribute(), $filter, $filterOptions)
81-
);
85+
$schema->addCustomSerializer(
86+
new TransformingFilterSerializer($property->getAttribute(), $filter, $filterOptions)
87+
);
88+
}
8289
}
8390
}
8491
}

tests/Basic/FilterTest.php

Lines changed: 128 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
namespace PHPModelGenerator\Tests\Basic;
44

5+
use DateTime;
6+
use PHPModelGenerator\Exception\ErrorRegistryException;
57
use PHPModelGenerator\Exception\InvalidFilterException;
68
use PHPModelGenerator\Exception\SchemaException;
79
use PHPModelGenerator\Exception\ValidationException;
810
use PHPModelGenerator\Filter\Trim;
911
use PHPModelGenerator\Model\GeneratorConfiguration;
12+
use PHPModelGenerator\PropertyProcessor\Filter\DateTimeFilter;
1013
use PHPModelGenerator\PropertyProcessor\Filter\FilterInterface;
1114
use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTest;
1215

@@ -99,18 +102,18 @@ public function getFilter(): array
99102
* @dataProvider validBuiltInFilterDataProvider
100103
*
101104
* @param string $template
102-
* @param string|null $input
105+
* @param array $input
103106
* @param string|null $expected
104107
*/
105-
public function testValidUsageOfBuiltInFilter(string $template, ?string $input, ?string $expected): void
108+
public function testValidUsageOfBuiltInFilter(string $template, array $input, ?string $expected): void
106109
{
107110
$className = $this->generateClassFromFileTemplate($template, ['"string"'], null, false);
108111

109-
$object = new $className(['property' => $input]);
112+
$object = new $className($input);
110113

111114
$this->assertSame($object->getProperty(), $expected);
112115
// make sure the raw inout isn't affected by the filter
113-
$this->assertSame($input, $object->getRawModelDataInput()['property']);
116+
$this->assertSame($input, $object->getRawModelDataInput());
114117
}
115118

116119
/**
@@ -140,11 +143,12 @@ public function validBuiltInFilterDataProvider(): array
140143
return $this->combineDataProvider(
141144
$this->validTrimDataFormatProvider(),
142145
[
143-
'Null' => [null, null],
144-
'Empty string' => ['', ''],
145-
'String containing only whitespaces' => [" \t \n \r ", ''],
146-
'Numeric string' => [' 12 ', '12'],
147-
'Text' => [' Hello World! ', 'Hello World!'],
146+
'Optional Value not provided' => [[], null],
147+
'Null' => [['property' => null], null],
148+
'Empty string' => [['property' => ''], ''],
149+
'String containing only whitespaces' => [['property' => " \t \n \r "], ''],
150+
'Numeric string' => [['property' => ' 12 '], '12'],
151+
'Text' => [['property' => ' Hello World! '], 'Hello World!'],
148152
]
149153
);
150154
}
@@ -301,4 +305,119 @@ public function multipleFilterDataProvider(): array
301305
'mixed string' => [" \t Hello World! ", 'HELLO WORLD!'],
302306
];
303307
}
308+
309+
/**
310+
* @dataProvider validDateTimeFilterDataProvider
311+
*/
312+
public function testTransformingFilter(array $input, ?string $expected): void
313+
{
314+
$className = $this->generateClassFromFile(
315+
'TransformingFilter.json',
316+
(new GeneratorConfiguration())->setImmutable(false)->setSerialization(true)
317+
);
318+
319+
$object = new $className($input);
320+
321+
if ($expected === null) {
322+
$this->assertNull($object->getCreated());
323+
} else {
324+
$expectedDateTime = new DateTime($expected);
325+
326+
$this->assertInstanceOf(DateTime::class, $object->getCreated());
327+
$this->assertSame($expectedDateTime->format(DATE_ATOM), $object->getCreated()->format(DATE_ATOM));
328+
}
329+
330+
// test if the setter accepts the raw model data
331+
if (isset($input['created'])) {
332+
$object->setCreated($input['created']);
333+
334+
if ($expected === null) {
335+
$this->assertNull($object->getCreated());
336+
} else {
337+
$expectedDateTime = new DateTime($expected);
338+
339+
$this->assertInstanceOf(DateTime::class, $object->getCreated());
340+
$this->assertSame($expectedDateTime->format(DATE_ATOM), $object->getCreated()->format(DATE_ATOM));
341+
342+
// test if the setter accepts a DateTime object
343+
$object->setCreated($expectedDateTime);
344+
345+
$this->assertInstanceOf(DateTime::class, $object->getCreated());
346+
$this->assertSame($expectedDateTime->format(DATE_ATOM), $object->getCreated()->format(DATE_ATOM));
347+
}
348+
}
349+
350+
// test if the model can be serialized
351+
$expectedSerialization = [
352+
'created' => $expected !== null ? (new DateTime($expected))->format(DATE_ISO8601) : null,
353+
'name' => null,
354+
];
355+
356+
$this->assertSame($expectedSerialization, $object->toArray());
357+
$this->assertSame(json_encode($expectedSerialization), $object->toJSON());
358+
}
359+
360+
public function validDateTimeFilterDataProvider(): array
361+
{
362+
return [
363+
'Optional Value not provided' => [[], null],
364+
'Null' => [['created' => null], null],
365+
'Empty string' => [['created' => ''], 'now'],
366+
'valid date' => [['created' => "12.12.2020 12:00"], '12.12.2020 12:00'],
367+
'valid DateTime constructor string' => [['created' => '+1 day'], '+1 day'],
368+
];
369+
}
370+
371+
public function testFilterExceptionsAreCaught(): void
372+
{
373+
$this->expectException(ErrorRegistryException::class);
374+
$this->expectExceptionMessage(<<<ERROR
375+
Invalid value for property created denied by filter dateTime: Invalid Date Time value "Hello"
376+
Invalid type for name. Requires string, got integer
377+
ERROR
378+
);
379+
380+
$className = $this->generateClassFromFile(
381+
'TransformingFilter.json',
382+
(new GeneratorConfiguration())->setCollectErrors(true)
383+
);
384+
385+
new $className(['created' => 'Hello', 'name' => 12]);
386+
}
387+
388+
public function testAdditionalFilterOptions(): void
389+
{
390+
$className = $this->generateClassFromFile(
391+
'FilterOptions.json',
392+
(new GeneratorConfiguration())->setSerialization(true)
393+
);
394+
395+
$object = new $className(['created' => '10122020']);
396+
397+
$this->assertSame((new DateTime('2020-12-10'))->format(DATE_ATOM), $object->getCreated()->format(DATE_ATOM));
398+
399+
$expectedSerialization = ['created' => '20201210'];
400+
$this->assertSame($expectedSerialization, $object->toArray());
401+
$this->assertSame(json_encode($expectedSerialization), $object->toJSON());
402+
}
403+
404+
public function testMultipleTransformingFiltersAppliedToOnePropertyThrowsAnException(): void
405+
{
406+
$this->expectException(SchemaException::class);
407+
$this->expectExceptionMessage(
408+
'Applying multiple transforming filters for property filteredProperty is not supported'
409+
);
410+
411+
$this->generateClassFromFile(
412+
'MultipleTransformingFilters.json',
413+
(new GeneratorConfiguration())->addFilter(
414+
new class () extends DateTimeFilter {
415+
public function getToken(): string
416+
{
417+
return 'customTransformer';
418+
}
419+
}
420+
)
421+
);
422+
}
304423
}

0 commit comments

Comments
 (0)