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
123 changes: 120 additions & 3 deletions src/Generator/Generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Laminas\Code\Generator\MethodGenerator;
use Laminas\Code\Generator\ParameterGenerator;
use Laminas\Code\Generator\PropertyGenerator;
use Laminas\Code\Generator\PropertyValueGenerator;
use Laminas\Code\Generator\TypeGenerator;

class Generator
Expand Down Expand Up @@ -85,11 +86,83 @@ public function generateProperties(PropertyCollection $properties): array
return $propertyGenerators;
}

/**
* Generates the property holding all input properties that are not explicitly
* declared in the schema (for schemas combining 'properties' and 'additionalProperties').
*/
public function generateAdditionalPropertiesProperty(PropertyInterface $additionalPropertiesItem): PropertyGenerator
{
$prop = new PropertyGenerator(
"additionalProperties",
new PropertyValueGenerator([], PropertyValueGenerator::TYPE_ARRAY_SHORT, PropertyValueGenerator::OUTPUT_SINGLE_LINE),
PropertyGenerator::FLAG_PRIVATE
);

$docBlock = new DocBlockGenerator(
"Properties from the input that are not explicitly declared in the schema",
null,
[new GenericTag("var", "array<string, " . trim($additionalPropertiesItem->typeAnnotation()) . ">")]
);
$docBlock->setWordWrap(false);
$prop->setDocBlock($docBlock);

if ($this->generatorRequest->isAtLeastPHP("7.4")) {
$prop->setType(TypeGenerator::fromTypeString("array"));
}

return $prop;
}

public function generateAdditionalPropertiesGetter(PropertyInterface $additionalPropertiesItem): MethodGenerator
{
$docBlock = new DocBlockGenerator(null, null, [
new ReturnTag("array<string, " . trim($additionalPropertiesItem->typeAnnotation()) . ">"),
]);
$docBlock->setWordWrap(false);

$method = new MethodGenerator(
'getAdditionalProperties',
[],
MethodGenerator::FLAG_PUBLIC,
'return $this->additionalProperties;',
$docBlock
);

if ($this->generatorRequest->isAtLeastPHP("7.0")) {
$method->setReturnType("array");
}

return $method;
}

public function generateAdditionalPropertiesSetter(PropertyInterface $additionalPropertiesItem): MethodGenerator
{
$docBlock = new DocBlockGenerator(null, null, [
new ParamTag("additionalProperties", ["array<string, " . trim($additionalPropertiesItem->typeAnnotation()) . ">"]),
new ReturnTag("self"),
]);
$docBlock->setWordWrap(false);

$method = new MethodGenerator(
'withAdditionalProperties',
[new ParameterGenerator("additionalProperties", "array")],
MethodGenerator::FLAG_PUBLIC,
"\$clone = clone \$this;\n\$clone->additionalProperties = \$additionalProperties;\n\nreturn \$clone;",
$docBlock
);

if ($this->generatorRequest->isAtLeastPHP("7.0")) {
$method->setReturnType("self");
}

return $method;
}

/**
* @param PropertyCollection $properties
* @return MethodGenerator
*/
public function generateBuildMethod(PropertyCollection $properties): MethodGenerator
public function generateBuildMethod(PropertyCollection $properties, ?PropertyInterface $additionalPropertiesItem = null): MethodGenerator
{
$requiredProperties = $properties->filter(PropertyCollectionFilterFactory::required());
$optionalProperties = $properties->filter(PropertyCollectionFilterFactory::optional());
Expand Down Expand Up @@ -148,6 +221,7 @@ public function generateBuildMethod(PropertyCollection $properties): MethodGener
$properties->generateJSONToTypeConversionCode($inputVarName, object: true) . "\n\n" .
'$obj = new self(' . join(", ", $constructorParams) . ');' . "\n" .
join("\n", $assignments) . "\n" .
$this->generateAdditionalPropertiesCollectionCode($properties, $additionalPropertiesItem, $inputVarName) .
'return $obj;',
$docBlock
);
Expand All @@ -159,11 +233,32 @@ public function generateBuildMethod(PropertyCollection $properties): MethodGener
return $method;
}

private function generateAdditionalPropertiesCollectionCode(PropertyCollection $properties, ?PropertyInterface $additionalPropertiesItem, string $inputVarName): string
{
if ($additionalPropertiesItem === null) {
return "";
}

$declaredKeys = [];
foreach ($properties as $property) {
$declaredKeys[] = var_export($property->key(), true);
}

$mapping = $additionalPropertiesItem->generateInputMappingExpr('$value');

return "foreach (get_object_vars(\$$inputVarName) as \$key => \$value) {\n" .
" if (in_array(\$key, [" . join(", ", $declaredKeys) . "], true)) {\n" .
" continue;\n" .
" }\n" .
" \$obj->additionalProperties[\$key] = {$mapping};\n" .
"}\n";
}

/**
* @param PropertyCollection $properties
* @return MethodGenerator
*/
public function generateToJSONMethod(PropertyCollection $properties): MethodGenerator
public function generateToJSONMethod(PropertyCollection $properties, ?PropertyInterface $additionalPropertiesItem = null): MethodGenerator
{
$docBlock = new DocBlockGenerator(
"Converts this object back to a simple array that can be JSON-serialized",
Expand All @@ -172,11 +267,24 @@ public function generateToJSONMethod(PropertyCollection $properties): MethodGene
);
$docBlock->setWordWrap(false);

$additionalPropertiesCode = "";
if ($additionalPropertiesItem !== null) {
$mapping = $additionalPropertiesItem->generateOutputMappingExpr('$value');

// Additional properties are written first so that declared properties
// always win in case of a key collision.
$additionalPropertiesCode =
"foreach (\$this->additionalProperties as \$key => \$value) {\n" .
" \$output[\$key] = {$mapping};\n" .
"}\n";
}

$method = new MethodGenerator(
'toJson',
[],
MethodGenerator::FLAG_PUBLIC,
'$output = [];' . "\n" .
$additionalPropertiesCode .
$properties->generateTypeToJSONConversionCode('output') . "\n\n" .
'return $output;',
$docBlock
Expand Down Expand Up @@ -241,7 +349,7 @@ public function generateValidateMethod(): MethodGenerator
* @param PropertyCollection $properties
* @return MethodGenerator
*/
public function generateCloneMethod(PropertyCollection $properties): MethodGenerator
public function generateCloneMethod(PropertyCollection $properties, ?PropertyInterface $additionalPropertiesItem = null): MethodGenerator
{
$clones = [];

Expand All @@ -252,6 +360,15 @@ public function generateCloneMethod(PropertyCollection $properties): MethodGener
}
}

if ($additionalPropertiesItem !== null) {
$cloneExpr = $additionalPropertiesItem->generateCloneExpr('$value');
if ($cloneExpr !== '$value') {
$clones[] = $this->generatorRequest->isAtLeastPHP("7.4")
? "\$this->additionalProperties = array_map(fn (\$value) => {$cloneExpr}, \$this->additionalProperties);"
: "\$this->additionalProperties = array_map(function (\$value) { return {$cloneExpr}; }, \$this->additionalProperties);";
}
}

return new MethodGenerator(
'__clone',
[],
Expand Down
2 changes: 1 addition & 1 deletion src/Generator/NamespaceInferrer.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public function inferNamespaceFromTargetDirectory(string $directory): string
}

if ($directory[0] !== "/") {
$directory = $workingDirectory . PATH_SEPARATOR . $directory;
$directory = $workingDirectory . DIRECTORY_SEPARATOR . $directory;
}

list($root, $composer) = $this->getComposerJSONForDirectory($directory);
Expand Down
6 changes: 5 additions & 1 deletion src/Generator/Property/NestedObjectProperty.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ public static function canHandleSchema(array $schema): bool
{
$isObject = isset($schema["type"]) && $schema["type"] === "object" || isset($schema["properties"]);
$isAssociativeArray = isset($schema["additionalProperties"]) && is_array($schema["additionalProperties"]);
$hasProperties = isset($schema["properties"]) && count($schema["properties"]) > 0;

return $isObject && !$isAssociativeArray;
// Schemas with declared properties are generated as classes, even when they
// also allow additional properties; purely map-like schemas (only
// 'additionalProperties') are handled by the array property types instead.
return $isObject && ($hasProperties || !$isAssociativeArray);
}

public function isComplex(): bool
Expand Down
11 changes: 11 additions & 0 deletions src/Generator/Property/OptionalPropertyDecorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ public function convertTypeToJSON(string $outputVarName = 'output'): string
$name = $this->inner->name();
$inner = $this->inner->convertTypeToJSON($outputVarName);

// Properties with a default value are declared non-nullable and initialized
// with their default, so isset() on them would always be true.
if (isset($this->schema()["default"])) {
return $inner;
}

return "if (isset(\$this->{$name})) {\n" . $this->indentCode($inner, 1) . "\n}";
}

Expand Down Expand Up @@ -136,6 +142,11 @@ public function cloneProperty(): ?string
$inner = $this->inner->cloneProperty();

if ($inner !== null) {
// See convertTypeToJSON(): properties with a default value are always set.
if (isset($this->schema()["default"])) {
return $inner;
}

return "if (isset(\$this->{$name})) {\n" . $this->indentCode($inner, 1) . "\n}";
}

Expand Down
12 changes: 0 additions & 12 deletions src/Generator/PropertyBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ class PropertyBuilder
*/
public static function buildPropertyFromSchema(GeneratorRequest $req, string $name, array $definition, bool $isRequired): PropertyInterface
{
self::testInvariants($definition);

foreach (self::$propertyTypes as $propertyType) {
if ($propertyType::canHandleSchema($definition)) {
/** @var PropertyInterface $property */
Expand All @@ -71,14 +69,4 @@ public static function buildPropertyFromSchema(GeneratorRequest $req, string $na
/** @psalm-suppress PossiblyFalseOperand */
throw new GeneratorException("cannot map type " . json_encode($definition));
}

private static function testInvariants(array $definition): void
{
$hasAdditionalProperties = isset($definition["additionalProperties"]) && is_array($definition["additionalProperties"]) && count($definition["additionalProperties"]) > 0;
$hasProperties = isset($definition["properties"]) && is_array($definition["properties"]) && count($definition["properties"]) > 0;

if ($hasProperties && $hasAdditionalProperties) {
throw new GeneratorException("using 'properties' and 'additionalProperties' in the same schema is currently not supported.");
}
}
}
42 changes: 39 additions & 3 deletions src/Generator/SchemaToClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,25 +77,37 @@ public function schemaToClass(GeneratorRequest $req): void
}
}

$additionalPropertiesItem = $this->buildAdditionalPropertiesItem($req, $schema);

foreach ($propertiesFromSchema as $property) {
$property->generateSubTypes($this);
}

$additionalPropertiesItem?->generateSubTypes($this);

$codeGenerator = new Generator($req);

$properties = [
...$properties,
...$codeGenerator->generateProperties($propertiesFromSchema),
];

if ($additionalPropertiesItem !== null) {
$properties[] = $codeGenerator->generateAdditionalPropertiesProperty($additionalPropertiesItem);
}

$methods = [
$codeGenerator->generateConstructor($propertiesFromSchema),
...$codeGenerator->generateGetterMethods($propertiesFromSchema),
...$codeGenerator->generateSetterMethods($propertiesFromSchema),
$codeGenerator->generateBuildMethod($propertiesFromSchema),
$codeGenerator->generateToJSONMethod($propertiesFromSchema),
...($additionalPropertiesItem !== null ? [
$codeGenerator->generateAdditionalPropertiesGetter($additionalPropertiesItem),
$codeGenerator->generateAdditionalPropertiesSetter($additionalPropertiesItem),
] : []),
$codeGenerator->generateBuildMethod($propertiesFromSchema, $additionalPropertiesItem),
$codeGenerator->generateToJSONMethod($propertiesFromSchema, $additionalPropertiesItem),
$codeGenerator->generateValidateMethod(),
$codeGenerator->generateCloneMethod($propertiesFromSchema),
$codeGenerator->generateCloneMethod($propertiesFromSchema, $additionalPropertiesItem),
];

$cls = new ClassGenerator(
Expand Down Expand Up @@ -131,4 +143,28 @@ public function schemaToClass(GeneratorRequest $req): void
$this->writer->writeFile($filename, $content);
}

/**
* Builds a pseudo-property representing the value type of 'additionalProperties'
* for schemas that combine 'properties' and 'additionalProperties'. Returns null
* when the schema does not use this combination; purely map-like schemas (only
* 'additionalProperties') are represented as plain array properties instead.
*
* @throws GeneratorException
*/
private function buildAdditionalPropertiesItem(GeneratorRequest $req, array $schema): ?Property\PropertyInterface
{
$hasProperties = isset($schema["properties"]) && count($schema["properties"]) > 0;
$hasAdditionalProperties = isset($schema["additionalProperties"]) && is_array($schema["additionalProperties"]) && count($schema["additionalProperties"]) > 0;

if (!$hasProperties || !$hasAdditionalProperties) {
return null;
}

if (isset($schema["properties"]["additionalProperties"])) {
throw new GeneratorException("schemas using 'additionalProperties' together with a regular property named 'additionalProperties' are not supported");
}

return PropertyBuilder::buildPropertyFromSchema($req, "additionalPropertiesItem", $schema["additionalProperties"], true);
}

}
Loading