From a6453502cba442aa4bf9f3e55177dfb0f28a443c Mon Sep 17 00:00:00 2001 From: Alden Weddleton Date: Thu, 16 Jul 2026 15:43:47 -0400 Subject: [PATCH] issue #340: do not apply the choice's maxOccurs bound to the chosen element's items count The choice's maxOccurs attribute bounds the number of times the choice group may repeat, not the number of times the chosen element may occur within the group. When the element inside the choice defines its own maxOccurs greater than 1 (or unbounded), the generated setter/adder wrongly rejected any content with more items than the choice's maxOccurs (usually 1), even though the element's own maxOccurs allows them. The element's items count is already constrained by its own maxOccurs rule, so the ChoiceMaxOccursRule now skips the count check in that case. --- src/File/Validation/ChoiceMaxOccursRule.php | 25 ++ src/File/Validation/MaxOccursRule.php | 2 +- tests/File/StructTest.php | 21 ++ tests/File/Validation/AbstractRule.php | 5 + .../Validation/ChoiceMaxOccursRuleTest.php | 91 +++++ .../ValidUnitTestsStructItemsChoiceType.php | 350 ++++++++++++++++++ .../generated/parsed_unit_tests_none.json | 54 +++ .../generated/parsed_unit_tests_start.json | 54 +++ tests/resources/unit_tests.wsdl | 10 + 9 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 tests/File/Validation/ChoiceMaxOccursRuleTest.php create mode 100644 tests/resources/generated/ValidUnitTestsStructItemsChoiceType.php diff --git a/src/File/Validation/ChoiceMaxOccursRule.php b/src/File/Validation/ChoiceMaxOccursRule.php index 0e7f6156..7b60481b 100644 --- a/src/File/Validation/ChoiceMaxOccursRule.php +++ b/src/File/Validation/ChoiceMaxOccursRule.php @@ -2,10 +2,35 @@ namespace WsdlToPhp\PackageGenerator\File\Validation; +/** + * The choice's maxOccurs attribute bounds the number of times the choice group may repeat, + * not the number of times the chosen element may occur within the group. When the element + * itself may occur more than once (its own maxOccurs is greater than 1 or unbounded), the + * element's occurrences count is constrained by its own maxOccurs rule, and applying the + * choice's bound to the element's items count would wrongly reject valid contents. + * + * @see https://github.com/WsdlToPhp/PackageGenerator/issues/340 + */ class ChoiceMaxOccursRule extends MaxOccursRule { public function name(): string { return 'choiceMaxOccurs'; } + + public function testConditions(string $parameterName, $value, bool $itemType = false): string + { + $elementMaxOccurs = $this->getAttribute()->getMetaValueFirstSet([ + 'maxOccurs', + 'maxoccurs', + 'MaxOccurs', + 'Maxoccurs', + ], 1); + + if ('unbounded' === $elementMaxOccurs || 1 < (int) $elementMaxOccurs) { + return ''; + } + + return parent::testConditions($parameterName, $value, $itemType); + } } diff --git a/src/File/Validation/MaxOccursRule.php b/src/File/Validation/MaxOccursRule.php index 9752dfd8..d5a0fb5a 100644 --- a/src/File/Validation/MaxOccursRule.php +++ b/src/File/Validation/MaxOccursRule.php @@ -45,7 +45,7 @@ public function symbol(): string * * @param mixed $value */ - final public function testConditions(string $parameterName, $value, bool $itemType = false): string + public function testConditions(string $parameterName, $value, bool $itemType = false): string { $test = ''; if ($this->getAttribute()->isArray() && ((is_scalar($value) && 'unbounded' !== $value) || (is_array($value) && !in_array('unbounded', $value)))) { diff --git a/tests/File/StructTest.php b/tests/File/StructTest.php index 5703bc10..770cb0eb 100755 --- a/tests/File/StructTest.php +++ b/tests/File/StructTest.php @@ -510,6 +510,27 @@ public function testStructValueListTypeFromUnitTests(): void } } + /** + * The choice elements have their own maxOccurs="5", the choice's maxOccurs (1) must + * not be applied to the elements' items count. + * + * @see https://github.com/WsdlToPhp/PackageGenerator/issues/340 + */ + public function testStructItemsChoiceTypeFromUnitTests(): void + { + $generator = self::unitTestsInstance(); + if (($model = $generator->getStructByName('ItemsChoiceType')) instanceof StructModel) { + $struct = new StructFile($generator, $model->getName()); + $struct + ->setModel($model) + ->write() + ; + $this->assertSameFileContent('ValidUnitTestsStructItemsChoiceType', $struct); + } else { + $this->fail('Unable to find ItemsChoiceType struct for file generation'); + } + } + public function testWriteDeliveryDetails(): void { $generator = self::deliveryServiceInstance(); diff --git a/tests/File/Validation/AbstractRule.php b/tests/File/Validation/AbstractRule.php index ffd69272..f85e58fa 100644 --- a/tests/File/Validation/AbstractRule.php +++ b/tests/File/Validation/AbstractRule.php @@ -103,6 +103,11 @@ public static function getOrderContractAddressDeliveryTypeInstance(bool $reset = return self::getClassInstance('orderContractInstance', 'AddressDelivery_Type', $reset); } + public static function getUnitTestsItemsChoiceTypeInstance(bool $reset = false) + { + return self::getClassInstance('unitTestsInstance', 'ItemsChoiceType', $reset); + } + public static function getEwsWorkingPeriodInstance(bool $reset = false) { // required for validating enumeration values diff --git a/tests/File/Validation/ChoiceMaxOccursRuleTest.php b/tests/File/Validation/ChoiceMaxOccursRuleTest.php new file mode 100644 index 00000000..b0b16b63 --- /dev/null +++ b/tests/File/Validation/ChoiceMaxOccursRuleTest.php @@ -0,0 +1,91 @@ +assertSame($instance, $instance->setItemIdentifier([1, 2, 3, 4, 5])); + } + + /** + * The ItemIdentifier + * Meta information extracted from the WSDL + * - choice: ItemIdentifier | ItemName + * - choiceMaxOccurs: 1 + * - choiceMinOccurs: 1 + * - maxOccurs: 5 + * - minOccurs: 1. + */ + public function testAddToItemIdentifierWithSeveralItemsMustPass(): void + { + $instance = self::getUnitTestsItemsChoiceTypeInstance(true); + + $this->assertSame($instance, $instance->setItemIdentifier([1])->addToItemIdentifier(2)->addToItemIdentifier(3)); + } + + /** + * The ItemIdentifier + * Meta information extracted from the WSDL + * - choice: ItemIdentifier | ItemName + * - choiceMaxOccurs: 1 + * - choiceMinOccurs: 1 + * - maxOccurs: 5 + * - minOccurs: 1. + */ + public function testSetItemIdentifierWithTooManyItemsMustThrowAnException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid count of 6, the number of elements contained by the property must be less than or equal to 5'); + + $instance = self::getUnitTestsItemsChoiceTypeInstance(true); + + $instance->setItemIdentifier([1, 2, 3, 4, 5, 6]); + } + + /** + * The ItemName + * Meta information extracted from the WSDL + * - choice: ItemIdentifier | ItemName + * - choiceMaxOccurs: 1 + * - choiceMinOccurs: 1 + * - maxOccurs: 5 + * - minOccurs: 1. + */ + public function testSetItemNameAfterItemIdentifierMustThrowAnException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The property ItemName can\'t be set as the property ItemIdentifier is already set. Only one property must be set among these properties: ItemName, ItemIdentifier.'); + + $instance = self::getUnitTestsItemsChoiceTypeInstance(true); + + $instance + ->setItemIdentifier([1, 2]) + ->setItemName(['one', 'two']) + ; + } +} diff --git a/tests/resources/generated/ValidUnitTestsStructItemsChoiceType.php b/tests/resources/generated/ValidUnitTestsStructItemsChoiceType.php new file mode 100644 index 00000000..e41ff05c --- /dev/null +++ b/tests/resources/generated/ValidUnitTestsStructItemsChoiceType.php @@ -0,0 +1,350 @@ +setItemIdentifier($itemIdentifier) + ->setItemName($itemName); + } + /** + * Get ItemIdentifier value + * @return int[]|null + */ + public function getItemIdentifier(): ?array + { + return $this->ItemIdentifier ?? null; + } + /** + * This method is responsible for validating the value(s) passed to the setItemIdentifier method + * This method is willingly generated in order to preserve the one-line inline validation within the setItemIdentifier method + * This has to validate that each item contained by the array match the itemType constraint + * @param array $values + * @return string A non-empty message if the values does not match the validation rules + */ + public static function validateItemIdentifierForArrayConstraintFromSetItemIdentifier(?array $values = []): string + { + if (!is_array($values)) { + return ''; + } + $message = ''; + $invalidValues = []; + foreach ($values as $itemsChoiceTypeItemIdentifierItem) { + // validation for constraint: itemType + if (!(is_int($itemsChoiceTypeItemIdentifierItem) || ctype_digit($itemsChoiceTypeItemIdentifierItem))) { + $invalidValues[] = is_object($itemsChoiceTypeItemIdentifierItem) ? get_class($itemsChoiceTypeItemIdentifierItem) : sprintf('%s(%s)', gettype($itemsChoiceTypeItemIdentifierItem), var_export($itemsChoiceTypeItemIdentifierItem, true)); + } + } + if (!empty($invalidValues)) { + $message = sprintf('The ItemIdentifier property can only contain items of type int, %s given', is_object($invalidValues) ? get_class($invalidValues) : (is_array($invalidValues) ? implode(', ', $invalidValues) : gettype($invalidValues))); + } + unset($invalidValues); + + return $message; + } + /** + * This method is responsible for validating the value(s) passed to the setItemIdentifier method + * This method is willingly generated in order to preserve the one-line inline validation within the setItemIdentifier method + * This has to validate that the property which is being set is the only one among the given choices + * @param mixed $value + * @return string A non-empty message if the values does not match the validation rules + */ + public function validateItemIdentifierForChoiceConstraintFromSetItemIdentifier($value): string + { + $message = ''; + if (is_null($value)) { + return $message; + } + $properties = [ + 'ItemName', + ]; + try { + foreach ($properties as $property) { + if (isset($this->{$property})) { + throw new InvalidArgumentException(sprintf('The property ItemIdentifier can\'t be set as the property %s is already set. Only one property must be set among these properties: ItemIdentifier, %s.', $property, implode(', ', $properties)), __LINE__); + } + } + } catch (InvalidArgumentException $e) { + $message = $e->getMessage(); + } + + return $message; + } + /** + * Set ItemIdentifier value + * This property belongs to a choice that allows only one property to exist. It is + * therefore removable from the request, consequently if the value assigned to this + * property is null, the property is removed from this object + * @throws InvalidArgumentException + * @throws InvalidArgumentException + * @param int[] $itemIdentifier + * @return \StructType\ApiItemsChoiceType + */ + public function setItemIdentifier(?array $itemIdentifier = null): self + { + // validation for constraint: array + if ('' !== ($itemIdentifierArrayErrorMessage = self::validateItemIdentifierForArrayConstraintFromSetItemIdentifier($itemIdentifier))) { + throw new InvalidArgumentException($itemIdentifierArrayErrorMessage, __LINE__); + } + // validation for constraint: choice(ItemIdentifier, ItemName) + if ('' !== ($itemIdentifierChoiceErrorMessage = self::validateItemIdentifierForChoiceConstraintFromSetItemIdentifier($itemIdentifier))) { + throw new InvalidArgumentException($itemIdentifierChoiceErrorMessage, __LINE__); + } + // validation for constraint: maxOccurs(5) + if (is_array($itemIdentifier) && count($itemIdentifier) > 5) { + throw new InvalidArgumentException(sprintf('Invalid count of %s, the number of elements contained by the property must be less than or equal to 5', count($itemIdentifier)), __LINE__); + } + if (is_null($itemIdentifier) || (is_array($itemIdentifier) && empty($itemIdentifier))) { + unset($this->ItemIdentifier); + } else { + $this->ItemIdentifier = $itemIdentifier; + } + + return $this; + } + /** + * This method is responsible for validating the value(s) passed to the addToItemIdentifier method + * This method is willingly generated in order to preserve the one-line inline validation within the addToItemIdentifier method + * This has to validate that the property which is being set is the only one among the given choices + * @param mixed $value + * @return string A non-empty message if the values does not match the validation rules + */ + public function validateItemForChoiceConstraintFromAddToItemIdentifier($value): string + { + $message = ''; + if (is_null($value)) { + return $message; + } + $properties = [ + 'ItemName', + ]; + try { + foreach ($properties as $property) { + if (isset($this->{$property})) { + throw new InvalidArgumentException(sprintf('The property ItemIdentifier can\'t be set as the property %s is already set. Only one property must be set among these properties: ItemIdentifier, %s.', $property, implode(', ', $properties)), __LINE__); + } + } + } catch (InvalidArgumentException $e) { + $message = $e->getMessage(); + } + + return $message; + } + /** + * Add item to ItemIdentifier value + * @throws InvalidArgumentException + * @param int $item + * @return \StructType\ApiItemsChoiceType + */ + public function addToItemIdentifier(int $item): self + { + // validation for constraint: itemType + if (!(is_int($item) || ctype_digit($item))) { + throw new InvalidArgumentException(sprintf('The ItemIdentifier property can only contain items of type int, %s given', is_object($item) ? get_class($item) : (is_array($item) ? implode(', ', $item) : gettype($item))), __LINE__); + } + // validation for constraint: choice(ItemIdentifier, ItemName) + if ('' !== ($itemChoiceErrorMessage = self::validateItemForChoiceConstraintFromAddToItemIdentifier($item))) { + throw new InvalidArgumentException($itemChoiceErrorMessage, __LINE__); + } + // validation for constraint: maxOccurs(5) + if (is_array($this->ItemIdentifier) && count($this->ItemIdentifier) >= 5) { + throw new InvalidArgumentException(sprintf('You can\'t add anymore element to this property that already contains %s elements, the number of elements contained by the property must be less than or equal to 5', count($this->ItemIdentifier)), __LINE__); + } + $this->ItemIdentifier[] = $item; + + return $this; + } + /** + * Get ItemName value + * @return string[]|null + */ + public function getItemName(): ?array + { + return $this->ItemName ?? null; + } + /** + * This method is responsible for validating the value(s) passed to the setItemName method + * This method is willingly generated in order to preserve the one-line inline validation within the setItemName method + * This has to validate that each item contained by the array match the itemType constraint + * @param array $values + * @return string A non-empty message if the values does not match the validation rules + */ + public static function validateItemNameForArrayConstraintFromSetItemName(?array $values = []): string + { + if (!is_array($values)) { + return ''; + } + $message = ''; + $invalidValues = []; + foreach ($values as $itemsChoiceTypeItemNameItem) { + // validation for constraint: itemType + if (!is_string($itemsChoiceTypeItemNameItem)) { + $invalidValues[] = is_object($itemsChoiceTypeItemNameItem) ? get_class($itemsChoiceTypeItemNameItem) : sprintf('%s(%s)', gettype($itemsChoiceTypeItemNameItem), var_export($itemsChoiceTypeItemNameItem, true)); + } + } + if (!empty($invalidValues)) { + $message = sprintf('The ItemName property can only contain items of type string, %s given', is_object($invalidValues) ? get_class($invalidValues) : (is_array($invalidValues) ? implode(', ', $invalidValues) : gettype($invalidValues))); + } + unset($invalidValues); + + return $message; + } + /** + * This method is responsible for validating the value(s) passed to the setItemName method + * This method is willingly generated in order to preserve the one-line inline validation within the setItemName method + * This has to validate that the property which is being set is the only one among the given choices + * @param mixed $value + * @return string A non-empty message if the values does not match the validation rules + */ + public function validateItemNameForChoiceConstraintFromSetItemName($value): string + { + $message = ''; + if (is_null($value)) { + return $message; + } + $properties = [ + 'ItemIdentifier', + ]; + try { + foreach ($properties as $property) { + if (isset($this->{$property})) { + throw new InvalidArgumentException(sprintf('The property ItemName can\'t be set as the property %s is already set. Only one property must be set among these properties: ItemName, %s.', $property, implode(', ', $properties)), __LINE__); + } + } + } catch (InvalidArgumentException $e) { + $message = $e->getMessage(); + } + + return $message; + } + /** + * Set ItemName value + * This property belongs to a choice that allows only one property to exist. It is + * therefore removable from the request, consequently if the value assigned to this + * property is null, the property is removed from this object + * @throws InvalidArgumentException + * @throws InvalidArgumentException + * @param string[] $itemName + * @return \StructType\ApiItemsChoiceType + */ + public function setItemName(?array $itemName = null): self + { + // validation for constraint: array + if ('' !== ($itemNameArrayErrorMessage = self::validateItemNameForArrayConstraintFromSetItemName($itemName))) { + throw new InvalidArgumentException($itemNameArrayErrorMessage, __LINE__); + } + // validation for constraint: choice(ItemIdentifier, ItemName) + if ('' !== ($itemNameChoiceErrorMessage = self::validateItemNameForChoiceConstraintFromSetItemName($itemName))) { + throw new InvalidArgumentException($itemNameChoiceErrorMessage, __LINE__); + } + // validation for constraint: maxOccurs(5) + if (is_array($itemName) && count($itemName) > 5) { + throw new InvalidArgumentException(sprintf('Invalid count of %s, the number of elements contained by the property must be less than or equal to 5', count($itemName)), __LINE__); + } + if (is_null($itemName) || (is_array($itemName) && empty($itemName))) { + unset($this->ItemName); + } else { + $this->ItemName = $itemName; + } + + return $this; + } + /** + * This method is responsible for validating the value(s) passed to the addToItemName method + * This method is willingly generated in order to preserve the one-line inline validation within the addToItemName method + * This has to validate that the property which is being set is the only one among the given choices + * @param mixed $value + * @return string A non-empty message if the values does not match the validation rules + */ + public function validateItemForChoiceConstraintFromAddToItemName($value): string + { + $message = ''; + if (is_null($value)) { + return $message; + } + $properties = [ + 'ItemIdentifier', + ]; + try { + foreach ($properties as $property) { + if (isset($this->{$property})) { + throw new InvalidArgumentException(sprintf('The property ItemName can\'t be set as the property %s is already set. Only one property must be set among these properties: ItemName, %s.', $property, implode(', ', $properties)), __LINE__); + } + } + } catch (InvalidArgumentException $e) { + $message = $e->getMessage(); + } + + return $message; + } + /** + * Add item to ItemName value + * @throws InvalidArgumentException + * @param string $item + * @return \StructType\ApiItemsChoiceType + */ + public function addToItemName(string $item): self + { + // validation for constraint: itemType + if (!is_string($item)) { + throw new InvalidArgumentException(sprintf('The ItemName property can only contain items of type string, %s given', is_object($item) ? get_class($item) : (is_array($item) ? implode(', ', $item) : gettype($item))), __LINE__); + } + // validation for constraint: choice(ItemIdentifier, ItemName) + if ('' !== ($itemChoiceErrorMessage = self::validateItemForChoiceConstraintFromAddToItemName($item))) { + throw new InvalidArgumentException($itemChoiceErrorMessage, __LINE__); + } + // validation for constraint: maxOccurs(5) + if (is_array($this->ItemName) && count($this->ItemName) >= 5) { + throw new InvalidArgumentException(sprintf('You can\'t add anymore element to this property that already contains %s elements, the number of elements contained by the property must be less than or equal to 5', count($this->ItemName)), __LINE__); + } + $this->ItemName[] = $item; + + return $this; + } +} diff --git a/tests/resources/generated/parsed_unit_tests_none.json b/tests/resources/generated/parsed_unit_tests_none.json index 358d7685..8ee236ec 100644 --- a/tests/resources/generated/parsed_unit_tests_none.json +++ b/tests/resources/generated/parsed_unit_tests_none.json @@ -283,6 +283,60 @@ }, "name": "UserType", "__CLASS__": "WsdlToPhp\\PackageGenerator\\Model\\Struct" + }, + { + "attributes": [ + { + "containsElements": true, + "removableFromRequest": false, + "type": "int", + "inheritance": "", + "abstract": false, + "meta": { + "choice": [ + "ItemIdentifier", + "ItemName" + ], + "choiceMaxOccurs": 1, + "choiceMinOccurs": 1, + "maxOccurs": "5", + "minOccurs": "1", + "ref": "tns:ItemIdentifier" + }, + "name": "ItemIdentifier", + "__CLASS__": "WsdlToPhp\\PackageGenerator\\Model\\StructAttribute" + }, + { + "containsElements": true, + "removableFromRequest": false, + "type": "string", + "inheritance": "", + "abstract": false, + "meta": { + "choice": [ + "ItemIdentifier", + "ItemName" + ], + "choiceMaxOccurs": 1, + "choiceMinOccurs": 1, + "maxOccurs": "5", + "minOccurs": "1", + "ref": "tns:ItemName" + }, + "name": "ItemName", + "__CLASS__": "WsdlToPhp\\PackageGenerator\\Model\\StructAttribute" + } + ], + "restriction": false, + "struct": true, + "types": [], + "values": [], + "list": "", + "inheritance": "", + "abstract": false, + "meta": [], + "name": "ItemsChoiceType", + "__CLASS__": "WsdlToPhp\\PackageGenerator\\Model\\Struct" } ] }, diff --git a/tests/resources/generated/parsed_unit_tests_start.json b/tests/resources/generated/parsed_unit_tests_start.json index 4a0d7663..102b1247 100644 --- a/tests/resources/generated/parsed_unit_tests_start.json +++ b/tests/resources/generated/parsed_unit_tests_start.json @@ -283,6 +283,60 @@ }, "name": "UserType", "__CLASS__": "WsdlToPhp\\PackageGenerator\\Model\\Struct" + }, + { + "attributes": [ + { + "containsElements": true, + "removableFromRequest": false, + "type": "int", + "inheritance": "", + "abstract": false, + "meta": { + "choice": [ + "ItemIdentifier", + "ItemName" + ], + "choiceMaxOccurs": 1, + "choiceMinOccurs": 1, + "maxOccurs": "5", + "minOccurs": "1", + "ref": "tns:ItemIdentifier" + }, + "name": "ItemIdentifier", + "__CLASS__": "WsdlToPhp\\PackageGenerator\\Model\\StructAttribute" + }, + { + "containsElements": true, + "removableFromRequest": false, + "type": "string", + "inheritance": "", + "abstract": false, + "meta": { + "choice": [ + "ItemIdentifier", + "ItemName" + ], + "choiceMaxOccurs": 1, + "choiceMinOccurs": 1, + "maxOccurs": "5", + "minOccurs": "1", + "ref": "tns:ItemName" + }, + "name": "ItemName", + "__CLASS__": "WsdlToPhp\\PackageGenerator\\Model\\StructAttribute" + } + ], + "restriction": false, + "struct": true, + "types": [], + "values": [], + "list": "", + "inheritance": "", + "abstract": false, + "meta": [], + "name": "ItemsChoiceType", + "__CLASS__": "WsdlToPhp\\PackageGenerator\\Model\\Struct" } ] }, diff --git a/tests/resources/unit_tests.wsdl b/tests/resources/unit_tests.wsdl index 59a74290..95f2a68c 100644 --- a/tests/resources/unit_tests.wsdl +++ b/tests/resources/unit_tests.wsdl @@ -62,6 +62,16 @@ + + + + + + + + + +