Skip to content

Commit 3709b2f

Browse files
committed
Make sure filters are executed in the correct order
Implement transformed value pass through for filters which are executed before a transforming filter if an already transformed value is provided Add some provider documentation
1 parent 7e369e7 commit 3709b2f

File tree

9 files changed

+177
-36
lines changed

9 files changed

+177
-36
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ The base object for generating models is the *Generator*. After you have created
5050
(new Generator())
5151
->generateModels(new RecursiveDirectoryProvider(__DIR__ . '/schema'), __DIR__ . '/result');
5252
```
53+
The first parameter of the *generateModels* method must be a class implementing the *SchemaProviderInterface*. The provider fetches the JSON schema files and provides them for the generator. The following providers are available:
54+
55+
Provider | Description
56+
--- | ---
57+
RecursiveDirectoryProvider | Fetches all *.json files from the given source directory. Each file must contain a JSON Schema object definition on the top level
58+
OpenAPIv3Provider | Fetches all objects defined in the `#/components/schemas section` of an Open API v3 spec file
59+
60+
The second parameter must point to an existing and empty directory (you may use the `generateModelDirectory` helper method to create your destination directory). This directory will contain the generated PHP classes after the generator is finished.
5361

5462
As an optional parameter you can set up a *GeneratorConfiguration* object to configure your Generator and/or use the method *generateModelDirectory* to generate your model directory (will generate the directory if it doesn't exist; if it exists, all contained files and folders will be removed for a clean generation process):
5563

docs/source/gettingStarted.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ RecursiveDirectoryProvider Fetches all *.json files from the given source direc
3232
OpenAPIv3Provider Fetches all objects defined in the #/components/schemas section of an Open API v3 spec file
3333
=========================== ===========
3434
35-
The second parameter must point to an existing and empty directory. This directory will contain the generated PHP classes after the generator is finished.
35+
The second parameter must point to an existing and empty directory (you may use the *generateModelDirectory* helper method to create your destination directory). This directory will contain the generated PHP classes after the generator is finished.
3636

3737
As an optional parameter you can set up a *GeneratorConfiguration* object to configure your Generator and/or use the method *generateModelDirectory* to generate your model directory (will generate the directory if it doesn't exist; if it exists, all contained files and folders will be removed for a clean generation process):
3838

docs/source/nonStandardExtensions/filter.rst

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Filters can be either supplied as a string or as a list of filters (multiple fil
2323
}
2424
}
2525
26+
If multiple filters are applied to a single property they will be executed in the order of their definition inside the JSON Schema.
27+
2628
If a list is used filters may include additional option parameters. In this case a single filter must be provided as an object with the key **filter** defining the filter:
2729

2830
.. code-block:: json
@@ -47,17 +49,19 @@ Transforming filter
4749

4850
.. warning::
4951

50-
Read this section carefully and understand it if you want to use filters which transform the type of the property
52+
Read this section carefully and understand it if you want to use filters which transform the type of the property without breaking your bones
53+
54+
You may keep it simple and skip this for your first tries and only experiment with non-transforming filters like the trim filter
5155

5256
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.
5357

5458
As the required check is executed before the filter a filter may transform a required value into a null value. Be aware when writing custom filters which transform values to not break your validation rules by adding filters to a property.
5559

56-
The return type of the last applied filter will be used to define the type of the property inside the generated model (in the example one section above given above the method **getCreated** will return a DateTime object). Additionally the generated model also accepts the transformed type as input type. So **setCreated** will accept a string and a DateTime object. If an already transformed value is provided the filter which transforms the value will **not** be executed.
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.
5761

5862
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.
5963

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.
64+
The return type of the transforming filter will be used to define the type of the property inside the generated model (in the example one section above given above the method **getCreated** will return a DateTime object). Additionally the generated model also accepts the transformed type as input type. So **setCreated** will accept a string and a DateTime object. If an already transformed value is provided the filter which transforms the value will **not** be executed. Also all filters which are defined before the transformation will **not** be executed (eg. a trim filter before a dateTime filter will not be executed if a DateTime object is provided).
6165

6266
Builtin filter
6367
--------------
@@ -159,15 +163,15 @@ Let's have a look how the generated model behaves:
159163
Additional options
160164
~~~~~~~~~~~~~~~~~~
161165

162-
================ ============= ===========
166+
======================= ============= ===========
163167
Option Default value Description
164-
================ ============= ===========
168+
======================= ============= ===========
165169
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)
166170
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
167171
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)
168172
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)
169173
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-
================ ============= ===========
174+
======================= ============= ===========
171175

172176
.. hint::
173177

src/Model/Property/Property.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class Property implements PropertyInterface
3636
protected $defaultValue;
3737

3838
/** @var Validator[] */
39-
protected $validator = [];
39+
protected $validators = [];
4040
/** @var Schema */
4141
protected $schema;
4242
/** @var PropertyDecoratorInterface[] */
@@ -150,7 +150,7 @@ public function getDescription(): string
150150
*/
151151
public function addValidator(PropertyValidatorInterface $validator, int $priority = 99): PropertyInterface
152152
{
153-
$this->validator[] = new Validator($validator, $priority);
153+
$this->validators[] = new Validator($validator, $priority);
154154

155155
return $this;
156156
}
@@ -160,15 +160,15 @@ public function addValidator(PropertyValidatorInterface $validator, int $priorit
160160
*/
161161
public function getValidators(): array
162162
{
163-
return $this->validator;
163+
return $this->validators;
164164
}
165165

166166
/**
167167
* @inheritdoc
168168
*/
169169
public function filterValidators(callable $filter): PropertyInterface
170170
{
171-
$this->validator = array_filter($this->validator, $filter);
171+
$this->validators = array_filter($this->validators, $filter);
172172

173173
return $this;
174174
}
@@ -179,7 +179,7 @@ public function filterValidators(callable $filter): PropertyInterface
179179
public function getOrderedValidators(): array
180180
{
181181
usort(
182-
$this->validator,
182+
$this->validators,
183183
function (Validator $validator, Validator $comparedValidator) {
184184
if ($validator->getPriority() == $comparedValidator->getPriority()) {
185185
return 0;
@@ -192,7 +192,7 @@ function (Validator $validator, Validator $comparedValidator) {
192192
function (Validator $validator) {
193193
return $validator->getValidator();
194194
},
195-
$this->validator
195+
$this->validators
196196
);
197197
}
198198

src/Model/Property/PropertyInterface.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ public function getDescription(): string;
6767
/**
6868
* Add a validator for the property
6969
*
70+
* The priority is used to order the validators applied to a property.
71+
* The validators with the lowest priority number will be executed first.
72+
*
73+
* Priority 1: Required checks
74+
* Priority 2: Type Checks
75+
* Priority 3: Enum Checks
76+
* Priority 10+: Filter validators
77+
* Priority 99: Default priority used for casual validators
78+
* Priority 100: Validators for compositions
79+
*
7080
* @param PropertyValidatorInterface $validator
7181
* @param int $priority
7282
*

src/Model/Validator/FilterValidator.php

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,14 @@ class FilterValidator extends PropertyTemplateValidator
2727
* @param GeneratorConfiguration $generatorConfiguration
2828
* @param FilterInterface $filter
2929
* @param PropertyInterface $property
30-
* @param Schema $schema
3130
* @param array $filterOptions
3231
*
33-
* @throws ReflectionException
3432
* @throws SchemaException
3533
*/
3634
public function __construct(
3735
GeneratorConfiguration $generatorConfiguration,
3836
FilterInterface $filter,
3937
PropertyInterface $property,
40-
Schema $schema,
4138
array $filterOptions = []
4239
) {
4340
if (!empty($filter->getAcceptedTypes()) &&
@@ -62,7 +59,7 @@ public function __construct(
6259
),
6360
DIRECTORY_SEPARATOR . 'Validator' . DIRECTORY_SEPARATOR . 'Filter.phptpl',
6461
[
65-
'skipTransformedValuesCheck' => $this->getTransformedCheck($filter, $property, $schema),
62+
'skipTransformedValuesCheck' => false,
6663
// check if the given value has a type matched by the filter
6764
'typeCheck' => !empty($filter->getAcceptedTypes())
6865
? '($value !== null && (!is_' .
@@ -83,33 +80,35 @@ public function __construct(
8380
}
8481

8582
/**
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.
83+
* Make sure the filter is only executed if a non-transformed value is provided.
8884
* This is required as a setter (eg. for a string property which is modified by the DateTime filter into a DateTime
8985
* object) also accepts a transformed value (in this case a DateTime object).
86+
* If transformed values are provided neither filters defined before the transforming filter in the filter chain nor
87+
* the transforming filter must be executed as they are only compatible with the original value
9088
*
91-
* @param FilterInterface $filter
89+
* @param TransformingFilterInterface $filter
9290
* @param PropertyInterface $property
9391
* @param Schema $schema
9492
*
95-
* @return string
93+
* @return self
9694
*
9795
* @throws ReflectionException
9896
*/
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();
97+
public function addTransformedCheck(
98+
TransformingFilterInterface $filter,
99+
PropertyInterface $property,
100+
Schema $schema
101+
): self {
102+
$typeAfterFilter = (new ReflectionMethod($filter->getFilter()[0], $filter->getFilter()[1]))->getReturnType();
104103

105-
if ($typeAfterFilter &&
106-
$typeAfterFilter->getName() &&
107-
!in_array($typeAfterFilter->getName(), $filter->getAcceptedTypes())
108-
) {
109-
return (new ReflectionTypeCheckValidator($typeAfterFilter, $property, $schema))->getCheck();
110-
}
104+
if ($typeAfterFilter &&
105+
$typeAfterFilter->getName() &&
106+
!in_array($typeAfterFilter->getName(), $filter->getAcceptedTypes())
107+
) {
108+
$this->templateValues['skipTransformedValuesCheck'] =
109+
(new ReflectionTypeCheckValidator($typeAfterFilter, $property, $schema))->getCheck();
111110
}
112111

113-
return '';
112+
return $this;
114113
}
115-
}
114+
}

src/PropertyProcessor/Filter/FilterProcessor.php

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ public function process(
4545
}
4646

4747
$hasTransformingFilter = false;
48+
// apply a different priority to each filter to make sure the order is kept
49+
$filterPriority = 10;
4850

4951
foreach ($filterList as $filterToken) {
5052
$filterOptions = [];
@@ -58,8 +60,8 @@ public function process(
5860
}
5961

6062
$property->addValidator(
61-
new FilterValidator($generatorConfiguration, $filter, $property, $schema, $filterOptions),
62-
3
63+
new FilterValidator($generatorConfiguration, $filter, $property, $filterOptions),
64+
$filterPriority++
6365
);
6466

6567
if ($filter instanceof TransformingFilterInterface) {
@@ -78,6 +80,7 @@ public function process(
7880
$typeAfterFilter->getName() &&
7981
$property->getType() !== $typeAfterFilter->getName()
8082
) {
83+
$this->addTransformedValuePassThrough($property, $schema, $filter);
8184
$this->extendTypeCheckValidatorToAllowTransformedValue($property, $schema, $typeAfterFilter);
8285

8386
$property->setType($property->getType(), $typeAfterFilter->getName());
@@ -90,6 +93,32 @@ public function process(
9093
}
9194
}
9295

96+
/**
97+
* Apply a check to each FilterValidator which is already associated with the given property to pass through values
98+
* which are already transformed.
99+
* By adding the pass through eg. a trim filter executed before a dateTime transforming filter will not be executed
100+
* if a DateTime object is provided for the property
101+
*
102+
* @param PropertyInterface $property
103+
* @param Schema $schema
104+
* @param TransformingFilterInterface $filter
105+
*
106+
* @throws ReflectionException
107+
*/
108+
private function addTransformedValuePassThrough(
109+
PropertyInterface $property,
110+
Schema $schema,
111+
TransformingFilterInterface $filter
112+
): void {
113+
foreach ($property->getValidators() as $validator) {
114+
$validator = $validator->getValidator();
115+
116+
if ($validator instanceof FilterValidator) {
117+
$validator->addTransformedCheck($filter, $property, $schema);
118+
}
119+
}
120+
}
121+
93122
/**
94123
* Extend a type check of the given property so the type check also allows the type of $typeAfterFilter. This is
95124
* used to allow also already transformed values as valid input values

tests/Basic/FilterTest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace PHPModelGenerator\Tests\Basic;
44

55
use DateTime;
6+
use Exception;
67
use PHPModelGenerator\Exception\ErrorRegistryException;
78
use PHPModelGenerator\Exception\InvalidFilterException;
89
use PHPModelGenerator\Exception\SchemaException;
@@ -11,6 +12,8 @@
1112
use PHPModelGenerator\Model\GeneratorConfiguration;
1213
use PHPModelGenerator\PropertyProcessor\Filter\DateTimeFilter;
1314
use PHPModelGenerator\PropertyProcessor\Filter\FilterInterface;
15+
use PHPModelGenerator\PropertyProcessor\Filter\TransformingFilterInterface;
16+
use PHPModelGenerator\PropertyProcessor\Filter\TrimFilter;
1417
use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTest;
1518

1619
/**
@@ -306,6 +309,42 @@ public function multipleFilterDataProvider(): array
306309
];
307310
}
308311

312+
/**
313+
* @dataProvider invalidCustomFilterDataProvider
314+
*
315+
* @param array $customInvalidFilter
316+
*/
317+
public function testAddFilterWithInvalidSerializerThrowsAnException(array $customInvalidFilter): void
318+
{
319+
$this->expectException(InvalidFilterException::class);
320+
$this->expectExceptionMessage('Invalid serializer callback for filter customTransformingFilter');
321+
322+
(new GeneratorConfiguration())->addFilter($this->getCustomTransformingFilter($customInvalidFilter));
323+
}
324+
325+
protected function getCustomTransformingFilter(
326+
array $customSerializer
327+
): TransformingFilterInterface {
328+
return new class ($customSerializer) extends TrimFilter implements TransformingFilterInterface {
329+
private $customSerializer;
330+
331+
public function __construct(array $customSerializer)
332+
{
333+
$this->customSerializer = $customSerializer;
334+
}
335+
336+
public function getToken(): string
337+
{
338+
return 'customTransformingFilter';
339+
}
340+
341+
public function getSerializer(): array
342+
{
343+
return $this->customSerializer;
344+
}
345+
};
346+
}
347+
309348
/**
310349
* @dataProvider validDateTimeFilterDataProvider
311350
*/
@@ -420,4 +459,44 @@ public function getToken(): string
420459
)
421460
);
422461
}
462+
463+
public function testFilterBeforeTransformingFilterIsExecutedIfNonTransformedValueIsProvided(): void
464+
{
465+
$this->expectException(ErrorRegistryException::class);
466+
$this->expectExceptionMessage(
467+
'Invalid value for property filteredProperty denied by filter exceptionFilter: ' .
468+
'Exception filter called with 12.12.2020'
469+
);
470+
471+
$className = $this->generateClassFromFile(
472+
'FilterPassThrough.json',
473+
(new GeneratorConfiguration())->addFilter(
474+
$this->getCustomFilter([self::class, 'exceptionFilter'], 'exceptionFilter')
475+
)
476+
);
477+
478+
new $className(['filteredProperty' => '12.12.2020']);
479+
}
480+
481+
public function testFilterBeforeTransformingFilterIsSkippedIfTransformedValueIsProvided(): void
482+
{
483+
$className = $this->generateClassFromFile(
484+
'FilterPassThrough.json',
485+
(new GeneratorConfiguration())->addFilter(
486+
$this->getCustomFilter([self::class, 'exceptionFilter'], 'exceptionFilter')
487+
)
488+
);
489+
490+
$object = new $className(['filteredProperty' => new DateTime('2020-12-10')]);
491+
492+
$this->assertSame(
493+
(new DateTime('2020-12-10'))->format(DATE_ATOM),
494+
$object->getFilteredProperty()->format(DATE_ATOM)
495+
);
496+
}
497+
498+
public static function exceptionFilter(string $value): void
499+
{
500+
throw new Exception("Exception filter called with $value");
501+
}
423502
}

0 commit comments

Comments
 (0)