From 78b38fea14def476334256aee1242b8b9d76d1d4 Mon Sep 17 00:00:00 2001 From: Geoff Appleby Date: Wed, 30 Dec 2020 12:45:18 -0800 Subject: [PATCH] [WIP] Typed data --- src/InnerList.php | 24 +++++++ src/Item.php | 19 ++++++ src/OuterList.php | 66 +++++++++++++++++++ src/Parser.php | 28 ++++---- src/Serializer.php | 32 ++++++++- src/TupleInterface.php | 7 ++ src/TupleTrait.php | 53 +++++++++++++++ tests/InnerListTest.php | 51 +++++++++++++++ tests/ItemTest.php | 66 +++++++++++++++++++ tests/OuterListTest.php | 126 ++++++++++++++++++++++++++++++++++++ tests/ParseListTest.php | 22 ++++--- tests/RulesetTest.php | 23 ++++--- tests/SerializeItemTest.php | 30 ++++++++- 13 files changed, 510 insertions(+), 37 deletions(-) create mode 100644 src/InnerList.php create mode 100644 src/Item.php create mode 100644 src/OuterList.php create mode 100644 src/TupleInterface.php create mode 100644 src/TupleTrait.php create mode 100644 tests/InnerListTest.php create mode 100644 tests/ItemTest.php create mode 100644 tests/OuterListTest.php diff --git a/src/InnerList.php b/src/InnerList.php new file mode 100644 index 0000000..0bf3fb6 --- /dev/null +++ b/src/InnerList.php @@ -0,0 +1,24 @@ +value = $value; + + if (is_null($parameters)) { + $this->parameters = new \stdClass(); + } else { + $this->parameters = $parameters; + } + } + + public function getIterator() + { + return new \ArrayIterator($this->value); + } +} diff --git a/src/Item.php b/src/Item.php new file mode 100644 index 0000000..89d6eef --- /dev/null +++ b/src/Item.php @@ -0,0 +1,19 @@ +value = $value; + + if (is_null($parameters)) { + $this->parameters = new \stdClass(); + } else { + $this->parameters = $parameters; + } + } +} diff --git a/src/OuterList.php b/src/OuterList.php new file mode 100644 index 0000000..295aa94 --- /dev/null +++ b/src/OuterList.php @@ -0,0 +1,66 @@ +value = $value; + } + + private static function validateItemType($value): void + { + if (is_object($value)) { + if (!($value instanceof TupleInterface)) { + throw new \InvalidArgumentException(); + } + } elseif (is_array($value)) { + if (count($value) != 2) { + throw new \InvalidArgumentException(); + } + } else { + throw new \InvalidArgumentException(); + } + } + + public function getIterator() + { + return new \ArrayIterator($this->value); + } + + public function offsetExists($offset) + { + return isset($this->value[$offset]); + } + + public function offsetGet($offset) + { + return $this->value[$offset] ?? null; + } + + public function offsetSet($offset, $value) + { + static::validateItemType($value); + + if (is_null($offset)) { + $this->value[] = $value; + } else { + $this->value[$offset] = $value; + } + } + + public function offsetUnset($offset) + { + unset($this->value[$offset]); + } +} diff --git a/src/Parser.php b/src/Parser.php index 45ce9dc..744cc4a 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -18,7 +18,7 @@ public static function parseDictionary(string $string): \stdClass $value->{$key} = self::parseItemOrInnerList($string); } else { // Bare boolean true value. - $value->{$key} = [true, self::parseParameters($string)]; + $value->{$key} = new Item(true, self::parseParameters($string)); } // OWS (optional whitespace) before comma. @@ -44,9 +44,9 @@ public static function parseDictionary(string $string): \stdClass return $value; } - public static function parseList(string $string): array + public static function parseList(string $string): OuterList { - $value = []; + $value = new OuterList(); $string = ltrim($string, ' '); @@ -76,7 +76,7 @@ public static function parseList(string $string): array return $value; } - private static function parseItemOrInnerList(string &$string): array + private static function parseItemOrInnerList(string &$string): TupleInterface { if ($string[0] === '(') { return self::parseInnerList($string); @@ -85,7 +85,7 @@ private static function parseItemOrInnerList(string &$string): array } } - private static function parseInnerList(string &$string): array + private static function parseInnerList(string &$string): InnerList { $value = []; @@ -96,10 +96,10 @@ private static function parseInnerList(string &$string): array if ($string[0] === ')') { $string = substr($string, 1); - return [ + return new InnerList( $value, - self::parseParameters($string), - ]; + self::parseParameters($string) + ); } $value[] = self::doParseItem($string); @@ -115,10 +115,10 @@ private static function parseInnerList(string &$string): array /** * @param string $string * - * @return array + * @return \gapple\StructuredFields\Item * A [value, parameters] tuple. */ - public static function parseItem(string $string): array + public static function parseItem(string $string): Item { $string = ltrim($string, ' '); @@ -137,15 +137,15 @@ public static function parseItem(string $string): array * * @param string $string * - * @return array + * @return \gapple\StructuredFields\Item * A [value, parameters] tuple. */ - private static function doParseItem(string &$string): array + private static function doParseItem(string &$string): Item { - return [ + return new Item( self::parseBareItem($string), self::parseParameters($string) - ]; + ); } /** diff --git a/src/Serializer.php b/src/Serializer.php index 9126998..4e11f0c 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -4,9 +4,33 @@ class Serializer { + /** + * Serialize and item with optional parameters. + * + * @param $value + * A bare value, or an Item object. + * @param object|null $parameters + * An optional object containing parameter values if a bare value is provided. + * + * @return string + * The serialized value. + */ public static function serializeItem($value, ?object $parameters = null): string { - $output = self::serializeBareItem($value); + if ($value instanceof Item) { + if (!is_null($parameters)) { + throw new \InvalidArgumentException( + 'Parameters argument is not allowed when serializing an Item object' + ); + } + + $bareValue = $value->value; + $parameters = $value->parameters; + } else { + $bareValue = $value; + } + + $output = self::serializeBareItem($bareValue); if (!empty($parameters)) { $output .= self::serializeParameters($parameters); @@ -15,8 +39,12 @@ public static function serializeItem($value, ?object $parameters = null): string return $output; } - public static function serializeList(array $value): string + public static function serializeList($value): string { + if ($value instanceof OuterList) { + $value = iterator_to_array($value->getIterator()); + } + $returnValue = array_map(function ($item) { if (is_array($item[0])) { return self::serializeInnerList($item[0], $item[1]); diff --git a/src/TupleInterface.php b/src/TupleInterface.php new file mode 100644 index 0000000..108e166 --- /dev/null +++ b/src/TupleInterface.php @@ -0,0 +1,7 @@ +value; + } elseif ($offset == 1) { + return $this->parameters; + } + return null; + } + + public function offsetSet($offset, $value) + { + if ($offset == 0) { + $this->value = $value; + } elseif ($offset == 1) { + $this->parameters = $value; + } + } + + public function offsetUnset($offset) + { + if ($offset == 0) { + $this->value = null; + } elseif ($offset == 1) { + $this->parameters = new \stdClass(); + } + } +} diff --git a/tests/InnerListTest.php b/tests/InnerListTest.php new file mode 100644 index 0000000..83cb775 --- /dev/null +++ b/tests/InnerListTest.php @@ -0,0 +1,51 @@ +assertInstanceOf(\stdClass::class, $item->parameters); + $this->assertEmpty(get_object_vars($item->parameters)); + } + + public function testArrayAccess() + { + $item = new InnerList( + [ + 'Test Value One', + 'Test Value Two', + ], + (object) ['paramKey' => 'param value'] + ); + + $this->assertEquals('Test Value One', $item->value[0]); + $this->assertEquals('Test Value One', $item[0][0]); + $this->assertEquals('param value', $item->parameters->paramKey); + $this->assertEquals('param value', $item[1]->paramKey); + } + + public function testIteration() + { + $list = new InnerList( + [ + 'Test Value One', + 'Test Value Two', + ], + (object) ['paramKey' => 'param value'] + ); + + $this->assertIsIterable($list); + + $this->assertEquals( + ['Test Value One', 'Test Value Two'], + iterator_to_array($list) + ); + } +} diff --git a/tests/ItemTest.php b/tests/ItemTest.php new file mode 100644 index 0000000..dffa46d --- /dev/null +++ b/tests/ItemTest.php @@ -0,0 +1,66 @@ +assertInstanceOf(\stdClass::class, $item->parameters); + $this->assertEmpty(get_object_vars($item->parameters)); + } + + public function testArrayAccess() + { + $item = new Item('Test Value', (object) ['paramKey' => 'param value']); + + $this->assertEquals('Test Value', $item->value); + $this->assertEquals('Test Value', $item[0]); + $this->assertEquals('param value', $item->parameters->paramKey); + $this->assertEquals('param value', $item[1]->paramKey); + } + + public function testArraySet() + { + $item = new Item('Test Value', (object) ['paramKey' => 'param value']); + + $item[0] = 'Modified Value'; + $item[1] = (object) ['paramKey' => 'Modified param value']; + $this->assertEquals('Modified Value', $item->value); + $this->assertEquals('Modified Value', $item[0]); + $this->assertEquals('Modified param value', $item->parameters->paramKey); + $this->assertEquals('Modified param value', $item[1]->paramKey); + } + + public function testArrayIndexIsset() + { + $item = new Item(true); + + $this->assertTrue(isset($item[0])); + $this->assertTrue(isset($item[1])); + $this->assertFalse(isset($item[2])); + } + + public function testArrayOutOfBounds() + { + $item = new Item(true); + + $this->assertEmpty($item[2]); + } + + public function testArrayUnset() + { + $item = new Item('Test Value', (object) ['paramKey' => 'param value']); + + unset($item[0]); + unset($item[1]); + + $this->assertEmpty($item->value); + $this->assertEquals(new \stdClass(), $item->parameters); + } +} diff --git a/tests/OuterListTest.php b/tests/OuterListTest.php new file mode 100644 index 0000000..77de8ff --- /dev/null +++ b/tests/OuterListTest.php @@ -0,0 +1,126 @@ +assertEquals('Test Value One', $item[0][0]); + } + + public function testArrayIsset() + { + $item = new OuterList([ + ['Test Value One', (object) []], + ['Test Value Two', (object) []], + ]); + + $this->assertTrue(isset($item[0])); + $this->assertTrue(isset($item[1])); + $this->assertFalse(isset($item[2])); + } + + public function testArrayAppend() + { + $item = new OuterList([ + ['Test Value One', (object) []], + ['Test Value Two', (object) []], + ]); + $item[] = ['Test Value Three', (object) []]; + + $this->assertEquals('Test Value Three', $item[2][0]); + } + + public function testArrayOverwrite() + { + $item = new OuterList([ + ['Test Value One', (object) []], + ['Test Value Two', (object) []], + ]); + $item[1] = ['Test Value Three', (object) []]; + + $this->assertEquals('Test Value One', $item[0][0]); + $this->assertEquals('Test Value Three', $item[1][0]); + } + + public function testArrayUnset() + { + $item = new OuterList([ + ['Test Value One', (object) []], + ['Test Value Two', (object) []], + ]); + unset($item[1]); + + $this->assertEquals('Test Value One', $item[0][0]); + $this->assertEmpty($item[1]); + } + + public function testIteration() + { + $listValues = [ + ['Test Value One', (object) []], + ['Test Value Two', (object) []], + ]; + $list = new OuterList($listValues); + + $this->assertIsIterable($list); + + $iterated = 0; + foreach ($list as $key => $value) { + $this->assertEquals($listValues[$key], $value); + $iterated++; + } + $this->assertEquals(count($listValues), $iterated); + } + + public function invalidItemProvider() + { + $items = []; + + // Bare items are not allowed, only: + // - raw array tuples (e.g. `[42, {}]`) + // - \gapple\StructuredFields\Item + // - \gapple\StructuredFields\InnerList + $items['integer'] = [42]; + $items['string'] = ['Test']; + $items['stdClass'] = [new \stdClass()]; + $items['DateTime'] = [new \DateTime()]; + + $items['array0'] = [[]]; + $items['array1'] = [[1]]; + $items['array3'] = [[1,2,3]]; + + return $items; + } + + /** + * @dataProvider invalidItemProvider + */ + public function testConstructInvalidItem($value) + { + $this->expectException(\InvalidArgumentException::class); + + new OuterList([$value]); + } + + /** + * @dataProvider invalidItemProvider + */ + public function testAppendInvalidItem($value) + { + $this->expectException(\InvalidArgumentException::class); + + $list = new OuterList(); + $list[] = $value; + } +} diff --git a/tests/ParseListTest.php b/tests/ParseListTest.php index 7b1293f..6f20652 100644 --- a/tests/ParseListTest.php +++ b/tests/ParseListTest.php @@ -2,6 +2,8 @@ namespace gapple\Tests\StructuredFields; +use gapple\StructuredFields\Item; +use gapple\StructuredFields\OuterList; use gapple\StructuredFields\Parser; use PHPUnit\Framework\TestCase; @@ -13,20 +15,20 @@ public function multipleStringProvider() $dataset[] = [ 'raw' => '"one", 1, 42;towel;panic=?0, "two"', - 'expected' => [ - ['one', (object) []], - [1, (object) []], - [42, (object) ['towel' => true, 'panic' => false]], - ['two', (object) []], - ] + 'expected' => new OuterList([ + new Item('one', (object) []), + new Item(1, (object) []), + new Item(42, (object) ['towel' => true, 'panic' => false]), + new Item('two', (object) []), + ]) ]; $dataset[] = [ 'raw' => '"\"Not\\\A;Brand";v="99", "Chromium";v="86"', - 'expected' => [ - ['"Not\\A;Brand', (object) ['v' => "99"]], - ['Chromium', (object) ['v' => "86"]], - ], + 'expected' => new OuterList([ + new Item('"Not\\A;Brand', (object) ['v' => "99"]), + new Item('Chromium', (object) ['v' => "86"]), + ]), ]; return $dataset; diff --git a/tests/RulesetTest.php b/tests/RulesetTest.php index 645730f..a5e4847 100644 --- a/tests/RulesetTest.php +++ b/tests/RulesetTest.php @@ -3,6 +3,9 @@ namespace gapple\Tests\StructuredFields; use gapple\StructuredFields\Bytes; +use gapple\StructuredFields\InnerList; +use gapple\StructuredFields\Item; +use gapple\StructuredFields\OuterList; use gapple\StructuredFields\ParseException; use gapple\StructuredFields\Parser; use gapple\StructuredFields\SerializeException; @@ -148,7 +151,7 @@ public function testSerializing($record) try { if ($record->header_type == 'item') { - $serializedValue = Serializer::serializeItem($record->expected[0], $record->expected[1]); + $serializedValue = Serializer::serializeItem($record->expected); } else { $serializedValue = Serializer::{'serialize' . ucfirst($record->header_type)}($record->expected); } @@ -176,11 +179,11 @@ public function testSerializing($record) * Convert the expected value of an item tuple. * * @param array $item - * @return array + * @return \gapple\StructuredFields\Item */ - private static function convertExpectedItem(array $item): array + private static function convertExpectedItem(array $item): Item { - return [self::convertValue($item[0]), self::convertParameters($item[1])]; + return new Item(self::convertValue($item[0]), self::convertParameters($item[1])); } /** @@ -209,28 +212,28 @@ private static function convertParameters(array $parameters): object * Convert the expected values of an inner list tuple. * * @param array $innerList - * @return array + * @return InnerList */ private static function convertInnerList(array $innerList) { $outputList = []; foreach ($innerList[0] as $value) { - $outputList[] = [self::convertValue($value[0]), self::convertParameters($value[1])]; + $outputList[] = new Item(self::convertValue($value[0]), self::convertParameters($value[1])); } - return [$outputList, self::convertParameters($innerList[1])]; + return new InnerList($outputList, self::convertParameters($innerList[1])); } /** * Convert the expected values of a list. * * @param array $list - * @return array + * @return OuterList */ - private static function convertExpectedList(array $list): array + private static function convertExpectedList(array $list): OuterList { - $output = []; + $output = new OuterList(); foreach ($list as $value) { if (is_array($value[0])) { diff --git a/tests/SerializeItemTest.php b/tests/SerializeItemTest.php index 92c7468..6a893e6 100644 --- a/tests/SerializeItemTest.php +++ b/tests/SerializeItemTest.php @@ -2,16 +2,44 @@ namespace gapple\Tests\StructuredFields; +use gapple\StructuredFields\Item; use gapple\StructuredFields\SerializeException; use gapple\StructuredFields\Serializer; use PHPUnit\Framework\TestCase; class SerializeItemTest extends TestCase { - public function testUnkownType() + public function testUnknownType() { $this->expectException(SerializeException::class); Serializer::serializeItem(new \stdClass()); } + + public function testNullValueItem() + { + $this->expectException(SerializeException::class); + $this->expectExceptionMessage('Unrecognized type'); + + Serializer::serializeItem(new Item(null)); + } + + public function testNoParameters() + { + $item = new Item(true); + + $result = Serializer::serializeItem($item); + + $this->assertEquals('?1', $result); + } + + public function testItemObjectWithParameters() + { + $this->expectException(\InvalidArgumentException::class); + + $parameters = new \stdClass(); + $item = new Item(true, $parameters); + + Serializer::serializeItem($item, $parameters); + } }