diff --git a/lib/Fhp/Segment/BaseDescriptor.php b/lib/Fhp/Segment/BaseDescriptor.php index 12347217..b7db851e 100644 --- a/lib/Fhp/Segment/BaseDescriptor.php +++ b/lib/Fhp/Segment/BaseDescriptor.php @@ -32,9 +32,11 @@ abstract class BaseDescriptor protected function __construct(\ReflectionClass $clazz) { // Use reflection to map PHP class fields to elements in the segment/Deg. - $implicitIndex = true; $nextIndex = 0; foreach (static::enumerateProperties($clazz) as $property) { + if ($nextIndex === null) { + throw new \InvalidArgumentException("Disallowed property $property after an @Unlimited field"); + } $docComment = $property->getDocComment() ?: ''; if (static::getBoolAnnotation('Ignore', $docComment)) { continue; // Skip @Ignore-d propeties. @@ -44,22 +46,35 @@ protected function __construct(\ReflectionClass $clazz) $descriptor = new ElementDescriptor(); $descriptor->field = $property->getName(); $maxCount = static::getIntAnnotation('Max', $docComment); + $unlimitedCount = static::getBoolAnnotation('Unlimited', $docComment); if ($type = static::getVarAnnotation($docComment)) { if (str_ends_with($type, '|null')) { // Nullable field $descriptor->optional = true; $type = substr($type, 0, -5); } if (str_ends_with($type, '[]')) { // Array/repeated field - if ($maxCount === null) { - throw new \InvalidArgumentException("Repeated property $property needs @Max() annotation"); - } - $descriptor->repeated = $maxCount; $type = substr($type, 0, -2); - // If a repeated field is followed by anything at all, there will be an empty entry for each possible - // repeated value (in extreme cases, there can be hundreds of consecutive `+`, for instance). - $nextIndex += $maxCount; + if ($unlimitedCount) { + $descriptor->repeated = PHP_INT_MAX; + // A repeated field of unlimited size cannot be followed by anything, because it would not be + // clear which of the following values still belong to the repeated field vs to the next field. + $nextIndex = null; + } elseif ($maxCount !== null) { + $descriptor->repeated = $maxCount; + // If there's another field value after this repeated field, then a serialized message will + // contain placeholders (i.e. empty field values separated by possibly hundreds of `+`) to fill + // up to the repeated field's maximum length, after which the next message continues at the next + // index. + $nextIndex += $maxCount; + } else { + throw new \InvalidArgumentException( + "Repeated property $property needs @Max(.) or (rarely) @Unlimited annotation" + ); + } } elseif ($maxCount !== null) { throw new \InvalidArgumentException("@Max() annotation not recognized on single $property"); + } elseif ($unlimitedCount) { + throw new \InvalidArgumentException("@Unlimited annotation not recognized on single $property"); } else { ++$nextIndex; // Singular field, so the index advances by 1. } @@ -90,7 +105,7 @@ protected function __construct(\ReflectionClass $clazz) throw new \InvalidArgumentException("No fields found in $clazz->name"); } ksort($this->elements); // Make sure elements are parsed in wire-format order. - $this->maxIndex = $nextIndex - 1; + $this->maxIndex = $nextIndex === null ? PHP_INT_MAX : $nextIndex - 1; } /** diff --git a/lib/Fhp/Segment/VPP/ParameterNamensabgleichPruefauftragV1.php b/lib/Fhp/Segment/VPP/ParameterNamensabgleichPruefauftragV1.php index ffe2c683..df2331f2 100644 --- a/lib/Fhp/Segment/VPP/ParameterNamensabgleichPruefauftragV1.php +++ b/lib/Fhp/Segment/VPP/ParameterNamensabgleichPruefauftragV1.php @@ -25,6 +25,6 @@ class ParameterNamensabgleichPruefauftragV1 extends BaseDeg public string $unterstuetztePaymentStatusReportDatenformate; - /** @var string[] @Max(999999) Max length each: 6 */ + /** @var string[] @Unlimited Max length each: 6 */ public array $vopPflichtigerZahlungsverkehrsauftrag; } diff --git a/lib/Fhp/Syntax/Serializer.php b/lib/Fhp/Syntax/Serializer.php index da2a7a7e..6c2ff267 100644 --- a/lib/Fhp/Syntax/Serializer.php +++ b/lib/Fhp/Syntax/Serializer.php @@ -110,7 +110,22 @@ private static function serializeElements($obj, BaseDescriptor $descriptor): arr throw new \InvalidArgumentException( "Expected array value for $descriptor->class.$elementDescriptor->field, got: $value"); } - for ($repetition = 0; $repetition < $elementDescriptor->repeated; ++$repetition) { + if ($elementDescriptor->repeated === PHP_INT_MAX) { + // For an uncapped repeated field (with @Unlimited), it must be the very last field and we do not + // need to insert padding elements, so we only output its actual contents. + if ($index !== $lastKey) { + throw new \AssertionError( + "Expected unlimited field at $index to be the last one, but the last one is $lastKey" + ); + } + $numOutputElements = count($value); + } else { + // For a capped repeated field (with @Max), we need to output the specified number of elements, such + // that subsequent fields will be at the right place. If this is the last field, the trailing empty + // elements will be trimmed away again by flattenAndTrimEnd() later. + $numOutputElements = $elementDescriptor->repeated; + } + for ($repetition = 0; $repetition < $numOutputElements; ++$repetition) { $serializedElements[$index + $repetition] = static::serializeElement( $value === null || $repetition >= count($value) ? null : $value[$repetition], $elementDescriptor->type, $isSegment); @@ -129,7 +144,7 @@ private static function serializeElements($obj, BaseDescriptor $descriptor): arr */ private static function serializeElement($value, $type, bool $fullySerialize) { - if (is_string($type)) { + if (is_string($type)) { // Scalar value / DE return static::serializeDataElement($value, $type); } elseif ($type->getName() === Bin::class) { /* @var Bin|null $value */ diff --git a/lib/Tests/Fhp/Segment/HIVPPSTest.php b/lib/Tests/Fhp/Segment/HIVPPSTest.php new file mode 100644 index 00000000..54cbf19d --- /dev/null +++ b/lib/Tests/Fhp/Segment/HIVPPSTest.php @@ -0,0 +1,44 @@ +hivpps = HIVPPSv1::createEmpty(); + $this->hivpps->setSegmentNumber(42); + $this->hivpps->maximaleAnzahlAuftraege = 43; + $this->hivpps->anzahlSignaturenMindestens = 44; + $this->hivpps->sicherheitsklasse = 45; + $this->hivpps->parameter = new ParameterNamensabgleichPruefauftragV1(); + $this->hivpps->parameter->maximaleAnzahlCreditTransferTransactionInformationOptIn = 1; + $this->hivpps->parameter->aufklaerungstextStrukturiert = true; + $this->hivpps->parameter->artDerLieferungPaymentStatusReport = 'Art'; + $this->hivpps->parameter->sammelzahlungenMitEinemAuftragErlaubt = false; + $this->hivpps->parameter->eingabeAnzahlEintraegeErlaubt = false; + $this->hivpps->parameter->unterstuetztePaymentStatusReportDatenformate = 'Test'; + } + + public function testPopulatedArray() + { + $this->hivpps->parameter->vopPflichtigerZahlungsverkehrsauftrag = ['HKFOO', 'HKBAR']; + + $serialized = $this->hivpps->serialize(); + $this->assertEquals("HIVPPS:42:1+43+44+45+1:J:Art:N:N:Test:HKFOO:HKBAR'", $serialized); + + /** @var HIVPPSv1 $parsed */ + $parsed = Parser::parseSegment($serialized, HIVPPSv1::class); + $this->assertEquals(['HKFOO', 'HKBAR'], $parsed->parameter->vopPflichtigerZahlungsverkehrsauftrag); + } +} diff --git a/lib/Tests/Fhp/Segment/HKVPPTest.php b/lib/Tests/Fhp/Segment/HKVPPTest.php new file mode 100644 index 00000000..99ab2814 --- /dev/null +++ b/lib/Tests/Fhp/Segment/HKVPPTest.php @@ -0,0 +1,28 @@ +setSegmentNumber(42); + $hkvpp->unterstuetztePaymentStatusReports->paymentStatusReportDescriptor = ['A', 'B', 'C']; + + $serialized = $hkvpp->serialize(); + $this->assertEquals("HKVPP:42:1+A:B:C'", $serialized); + + /** @var HKVPPv1 $hkvpp */ + $hkvpp = Parser::parseSegment($serialized, HKVPPv1::class); + $this->assertEquals(42, $hkvpp->getSegmentNumber()); + $this->assertEquals(['A', 'B', 'C'], $hkvpp->unterstuetztePaymentStatusReports->paymentStatusReportDescriptor); + } +}