Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 2 additions & 44 deletions src/PropertyProcessor/Filter/FilterProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,7 @@
use PHPModelGenerator\Model\Validator\Factory\Composition\OneOfValidatorFactory;
use PHPModelGenerator\Model\Validator\FilterValidator;
use PHPModelGenerator\Model\Validator\FormatValidator;
use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator;
use PHPModelGenerator\Model\Validator\PassThroughTypeCheckValidator;
use PHPModelGenerator\Model\Validator\PropertyValidator;
use PHPModelGenerator\Model\Validator\TypeCheckValidator;
use PHPModelGenerator\Utils\FilterReflection;
use PHPModelGenerator\Utils\RenderHelper;
use PHPModelGenerator\Utils\TypeCheck;
Expand Down Expand Up @@ -627,7 +624,7 @@ private function resolveOriginalBranchSchemas(
* @throws ReflectionException
* @throws SchemaException
*/
public function applyOutputType(
private function applyOutputType(
PropertyInterface $property,
TransformingFilterInterface $filter,
array $returnTypeNames,
Expand Down Expand Up @@ -678,45 +675,6 @@ public function applyOutputType(
}
}

/**
* Replace the property's TypeCheckValidator / MultiTypeCheckValidator with a
* PassThroughTypeCheckValidator that also allows the given pass-through type names.
*
* When called a second time, the TypeCheckValidator has already been replaced by a
* PassThroughTypeCheckValidator, which does not match the filter predicate, so the call
* is silently skipped.
*
* @param string[] $passThroughTypeNames
*/
public function extendTypeCheckValidatorToAllowTransformedValue(
PropertyInterface $property,
array $passThroughTypeNames,
): void {
$typeCheckValidator = null;

$property->filterValidators(static function (Validator $validator) use (&$typeCheckValidator): bool {
if (
is_a($validator->getValidator(), TypeCheckValidator::class) ||
is_a($validator->getValidator(), MultiTypeCheckValidator::class)
) {
$typeCheckValidator = $validator->getValidator();
return false;
}

return true;
});

if (
$typeCheckValidator instanceof TypeCheckValidator
|| $typeCheckValidator instanceof MultiTypeCheckValidator
) {
$property->addValidator(
new PassThroughTypeCheckValidator($passThroughTypeNames, $property, $typeCheckValidator),
2,
);
}
}

/**
* Build and return the Draft instance for the given property's schema.
*
Expand Down Expand Up @@ -744,7 +702,7 @@ private function resolveBuiltDraft(
*
* @param string[] $returnTypeNames Non-null return type names of the transforming filter.
*/
public function addTransformedValuePassThrough(
private function addTransformedValuePassThrough(
PropertyInterface $property,
TransformingFilterInterface $filter,
array $returnTypeNames,
Expand Down
111 changes: 97 additions & 14 deletions src/SchemaProcessor/PostProcessor/EnumPostProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use PHPModelGenerator\PropertyProcessor\Filter\FilterProcessor;
use PHPModelGenerator\Utils\ArrayHash;
use PHPModelGenerator\Utils\NormalizedName;
use PHPModelGenerator\Utils\TypeCheck;

/**
* Generates a PHP enum for enums from JSON schemas which are automatically mapped for properties holding the enum
Expand Down Expand Up @@ -62,14 +63,20 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu
foreach ($schema->getProperties() as $property) {
$json = $property->getJsonSchema()->getJson();

if (!isset($json['enum']) || !$this->validateEnum($property)) {
if (!isset($json['enum'])) {
continue;
}

$this->checkForExistingTransformingFilter($property);
// Filter incompatible values before validation so that e.g. a string-typed enum
// with a stray integer value is still valid (and the integer is removed with a warning).
$values = $this->filterValuesByDeclaredType($json, $property);

if (!$this->validateEnum($property, $values)) {
continue;
}

$values = $json['enum'];
$enumSignature = ArrayHash::hash($json, ['enum', 'enum-map', 'title', '$id']);
$this->checkForExistingTransformingFilter($property);
$enumSignature = ArrayHash::hash($json, ['enum', 'enum-map', 'title', '$id', 'type']);
$enumName = $json['title']
?? basename($json['$id'] ?? $schema->getClassName() . ucfirst($property->getName()));

Expand Down Expand Up @@ -113,6 +120,12 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu
$property->setDefaultValue("$name::$caseName", true);
}

// TransformingFilterOutputTypePostProcessor runs before user post-processors and
// therefore never sees the FilterValidator added above. Call the extension directly
// so that any TypeCheckValidator added by a "type" keyword is wrapped into a
// PassThroughTypeCheckValidator that accepts already-transformed enum instances.
TypeCheck::extendTypeCheckValidatorToAllowTransformedValue($property, [$name]);

// remove the enum validator as the validation is performed by the PHP enum
$property->filterValidators(
static fn(Validator $validator): bool => !is_a($validator->getValidator(), EnumValidator::class),
Expand Down Expand Up @@ -170,7 +183,7 @@ public function postProcess(): void
/**
* @throws SchemaException
*/
private function validateEnum(PropertyInterface $property): bool
private function validateEnum(PropertyInterface $property, array $values): bool
{
$throw = function (string $message) use ($property): void {
throw new SchemaException(
Expand All @@ -184,7 +197,7 @@ private function validateEnum(PropertyInterface $property): bool

$json = $property->getJsonSchema()->getJson();

$types = $this->getArrayTypes($json['enum']);
$types = $this->getArrayTypes($values);

// the enum must contain either only string values or provide a value map to resolve the values
if ($types !== ['string'] && !isset($json['enum-map'])) {
Expand All @@ -196,19 +209,21 @@ private function validateEnum(PropertyInterface $property): bool
}

if (isset($json['enum-map'])) {
asort($json['enum']);
if (is_array($json['enum-map'])) {
asort($json['enum-map']);
$sortedValues = $values;
asort($sortedValues);
$enumMap = $json['enum-map'];
if (is_array($enumMap)) {
asort($enumMap);
}

if (
!is_array($json['enum-map'])
|| $this->getArrayTypes(array_keys($json['enum-map'])) !== ['string']
!is_array($enumMap)
|| $this->getArrayTypes(array_keys($enumMap)) !== ['string']
|| count(array_uintersect(
$json['enum-map'],
$json['enum'],
$enumMap,
$sortedValues,
fn($a, $b): int => $a === $b ? 0 : 1,
)) !== count($json['enum'])
)) !== count($sortedValues)
) {
$throw('invalid enum map %s in file %s');
}
Expand All @@ -217,6 +232,74 @@ private function validateEnum(PropertyInterface $property): bool
return true;
}

/**
* Return the enum values restricted to those compatible with the declared "type" keyword.
* Removes values that can never satisfy the type constraint and emits a warning for each
* removed value so the developer is aware at generation time.
*/
private function filterValuesByDeclaredType(array $json, PropertyInterface $property): array
{
$values = $json['enum'];

if (!isset($json['type'])) {
return $values;
}

$declaredTypes = is_array($json['type']) ? $json['type'] : [$json['type']];

// Map JSON Schema type names to PHP gettype() return values
$phpTypeMap = [
'string' => ['string'],
'integer' => ['integer'],
'number' => ['integer', 'double'],
'boolean' => ['boolean'],
'null' => ['NULL'],
'array' => ['array'],
'object' => ['object'],
];

$allowedPhpTypes = [];
foreach ($declaredTypes as $declaredType) {
if (isset($phpTypeMap[$declaredType])) {
$allowedPhpTypes = array_merge($allowedPhpTypes, $phpTypeMap[$declaredType]);
}
}

if (empty($allowedPhpTypes)) {
return $values;
}

$compatibleValues = [];
$removedValues = [];

foreach ($values as $value) {
if (in_array(gettype($value), $allowedPhpTypes, true)) {
$compatibleValues[] = $value;
} else {
$removedValues[] = $value;
}
}

if (!empty($removedValues)) {
$typeLabel = implode('|', $declaredTypes);
$removedList = implode(', ', array_map(
static fn($value): string => var_export($value, true),
$removedValues,
));

echo sprintf(
"Warning: enum property '%s' in file %s declares type '%s' but contains incompatible values: %s."
. " These values have been removed from the generated enum.\n",
$property->getName(),
$property->getJsonSchema()->getFile(),
$typeLabel,
$removedList,
);
}

return $compatibleValues;
}

private function getArrayTypes(array $array): array
{
return array_unique(array_map(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@
use PHPModelGenerator\Model\Validator\AdditionalPropertiesValidator;
use PHPModelGenerator\Model\Validator\FilterValidator;
use PHPModelGenerator\Model\Validator\PatternPropertiesValidator;
use PHPModelGenerator\PropertyProcessor\Filter\FilterProcessor;
use PHPModelGenerator\Utils\TypeCheck;
use PHPModelGenerator\SchemaProcessor\PostProcessor\PostProcessor;
use PHPModelGenerator\Utils\FilterReflection;
use PHPModelGenerator\Utils\RenderHelper;
use PHPModelGenerator\Utils\TypeCheck;
use ReflectionException;

/**
Expand All @@ -26,7 +25,9 @@
* fully resolved (composition branches have been merged) before the output type formula is
* evaluated.
*
* This post-processor is the sole owner of extendTypeCheckValidatorToAllowTransformedValue:
* This post-processor calls TypeCheck::extendTypeCheckValidatorToAllowTransformedValue for
* filters applied during schema processing. EnumPostProcessor calls the same method directly
* for filters it adds after this post-processor has already run.
* FilterProcessor does NOT call it because the TypeCheckValidator may not yet exist at
* filter-processing time (composition case where the type comes from a sibling allOf branch).
*
Expand Down Expand Up @@ -95,10 +96,10 @@ private function processProperty(
return;
}

// Sole owner of type-check extension: replace TypeCheckValidator with
// PassThroughTypeCheckValidator that also accepts transformed types.
// Replace TypeCheckValidator with PassThroughTypeCheckValidator that also accepts
// transformed types, so already-transformed values bypass the scalar type check.
if (!empty($returnTypeNames)) {
(new FilterProcessor())->extendTypeCheckValidatorToAllowTransformedValue($property, $returnTypeNames);
TypeCheck::extendTypeCheckValidatorToAllowTransformedValue($property, $returnTypeNames);
}

// Compute output type using the bypass formula.
Expand Down
53 changes: 52 additions & 1 deletion src/Utils/TypeCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@

namespace PHPModelGenerator\Utils;

use PHPModelGenerator\Model\Property\PropertyInterface;
use PHPModelGenerator\Model\Validator;
use PHPModelGenerator\Model\Validator\MultiTypeCheckValidator;
use PHPModelGenerator\Model\Validator\PassThroughTypeCheckValidator;
use PHPModelGenerator\Model\Validator\TypeCheckValidator;

/**
* Utility for building runtime type-check expressions in generated PHP code.
* Utility for building runtime type-check expressions in generated PHP code,
* and for upgrading TypeCheckValidator instances to PassThroughTypeCheckValidator.
*
* Converts PHP type names to expressions like is_string($value) for primitives
* or $value instanceof ClassName for classes.
Expand Down Expand Up @@ -69,4 +76,48 @@ public static function buildNegatedCompound(array $typeNames): string

return '!(' . implode(' || ', $checks) . ')';
}

/**
* Replace the property's TypeCheckValidator / MultiTypeCheckValidator with a
* PassThroughTypeCheckValidator that also allows the given pass-through type names.
*
* This ensures that an already-transformed value (e.g. an enum instance produced by a
* transforming filter) bypasses the original scalar type check while non-conforming values
* are still rejected.
*
* When called a second time the TypeCheckValidator has already been replaced by a
* PassThroughTypeCheckValidator, which does not match the filter predicate, so the call
* is silently skipped.
*
* @param string[] $passThroughTypeNames Simple PHP type names of the transformed output
* (e.g. ['DateTime'] or ['MyEnum'])
*/
public static function extendTypeCheckValidatorToAllowTransformedValue(
PropertyInterface $property,
array $passThroughTypeNames,
): void {
$typeCheckValidator = null;

$property->filterValidators(static function (Validator $validator) use (&$typeCheckValidator): bool {
if (
is_a($validator->getValidator(), TypeCheckValidator::class) ||
is_a($validator->getValidator(), MultiTypeCheckValidator::class)
) {
$typeCheckValidator = $validator->getValidator();
return false;
}

return true;
});

if (
$typeCheckValidator instanceof TypeCheckValidator
|| $typeCheckValidator instanceof MultiTypeCheckValidator
) {
$property->addValidator(
new PassThroughTypeCheckValidator($passThroughTypeNames, $property, $typeCheckValidator),
2,
);
}
}
}
Loading
Loading