diff --git a/phpcs.xml b/phpcs.xml index bff466c..ce2a486 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -30,7 +30,6 @@ - diff --git a/src/JsonKvpObject.php b/src/JsonKvpObject.php index 43ca106..261825b 100644 --- a/src/JsonKvpObject.php +++ b/src/JsonKvpObject.php @@ -22,6 +22,6 @@ public function key():null|int|string { } public function valid():bool { - return isset($this->data[$this->key()]); + return isset($this->data[$this->key() ?? ""]); } } diff --git a/src/JsonObjectBuilder.php b/src/JsonObjectBuilder.php index 7760302..cf1a312 100644 --- a/src/JsonObjectBuilder.php +++ b/src/JsonObjectBuilder.php @@ -35,62 +35,115 @@ public function fromJsonString(string $jsonString):JsonObject { throw new JsonDecodeException($exception->getMessage()); } + // Ensure $json is of the expected type + if(!is_object($json) && !is_array($json) && !is_scalar($json) && $json !== null) { + throw new JsonDecodeException("Invalid JSON structure"); + } + return $this->fromJsonDecoded($json); } /** - * @param object|array|string|int|float|bool|null $jsonDecoded + * Create a JsonObject from a decoded JSON value. + * + * @param object|array|string|int|float|bool|null $jsonDecoded The decoded JSON value + * @return JsonObject The resulting JsonObject */ public function fromJsonDecoded( object|array|string|int|float|bool|null $jsonDecoded ):JsonObject { - if(is_array($jsonDecoded) - && !is_int(key($jsonDecoded))) { -// The JSON could represent a primitive indexed array, but the json could have -// been decoded as an associative array too. Deal with associative arrays first. - $jsonData = $this->fromJsonDecoded( - (object)$jsonDecoded - ); + // Handle associative arrays by converting to objects + if(is_array($jsonDecoded) && !is_int(key($jsonDecoded))) { + return $this->fromJsonDecoded((object)$jsonDecoded); } - elseif(is_null($jsonDecoded)) { - $jsonData = new JsonNullPrimitive(); - } - elseif(is_bool($jsonDecoded)) { - $jsonData = new JsonBoolPrimitive(); + + // Handle indexed arrays separately + if(is_array($jsonDecoded)) { + return $this->processArrayData($jsonDecoded); } - elseif(is_int($jsonDecoded)) { - $jsonData = new JsonIntPrimitive(); + + // Handle scalar and object types + $jsonData = match(true) { + is_null($jsonDecoded) => new JsonNullPrimitive(), + is_bool($jsonDecoded) => new JsonBoolPrimitive(), + is_int($jsonDecoded) => new JsonIntPrimitive(), + is_float($jsonDecoded) => new JsonFloatPrimitive(), + is_string($jsonDecoded) => new JsonStringPrimitive(), + default => $this->asJsonKvpObject($jsonDecoded), + }; + + // Set primitive value if applicable + if($jsonData instanceof JsonPrimitive) { + $jsonData = $jsonData->withPrimitiveValue($jsonDecoded); } - elseif(is_float($jsonDecoded)) { - $jsonData = new JsonFloatPrimitive(); + + return $jsonData; + } + + /** + * Process array data and convert it to a JsonArrayPrimitive. + * + * @param array $arrayData The array data to process + * @return JsonArrayPrimitive The resulting JsonArrayPrimitive + */ + private function processArrayData(array $arrayData): JsonArrayPrimitive { + $processedArray = []; + + foreach($arrayData as $key => $value) { + $processedArray[$key] = $this->processArrayElement($value); } - elseif(is_string($jsonDecoded)) { - $jsonData = new JsonStringPrimitive(); + + $jsonData = new JsonArrayPrimitive(); + return $jsonData->withPrimitiveValue($processedArray); + } + + /** + * Process an individual element from an array. + * + * @param mixed $element The element to process + * @return mixed The processed element + */ + private function processArrayElement(mixed $element): mixed { + if($element instanceof stdClass) { + return $this->asJsonKvpObject($element); } - elseif(is_array($jsonDecoded)) { - array_walk_recursive($jsonDecoded, function(&$element) { - if($element instanceof StdClass) { - $element = $this->fromObject( - $element, - JsonKvpObject::class - ); - } - }); - $jsonData = new JsonArrayPrimitive(); + + if(is_array($element)) { + $nestedArray = []; + foreach($element as $nestedKey => $nestedValue) { + $nestedArray[$nestedKey] = $this->processArrayElement($nestedValue); + } + return $nestedArray; } - else { - /** @var JsonKvpObject $jsonData */ - $jsonData = $this->fromObject( - $jsonDecoded, - JsonKvpObject::class - ); + + return $element; + } + + /** + * Create a JsonObject from an associative array. + * + * @param array $input The associative array to convert + * @return JsonObject The resulting JsonObject + * @throws JsonDecodeException If the JSON encoding fails + */ + public function fromAssociativeArray(array $input):JsonObject { + $jsonString = json_encode($input); + if($jsonString === false) { + throw new JsonDecodeException("Failed to encode array to JSON"); } + return $this->fromJsonString($jsonString); + } - if($jsonData instanceof JsonPrimitive) { - $jsonData = $jsonData->withPrimitiveValue($jsonDecoded); + public function asJsonKvpObject( + object $input, + ):JsonKvpObject { + $kvp = new JsonKvpObject(); + + foreach(get_object_vars($input) as $key => $value) { + $kvp = $kvp->with($key, $value); } - return $jsonData; + return $kvp; } public function fromFile(string $filePath):JsonObject { @@ -100,5 +153,4 @@ public function fromFile(string $filePath):JsonObject { return self::fromJsonString(file_get_contents($filePath) ?: ""); } - } diff --git a/src/JsonTypeException.php b/src/JsonTypeException.php new file mode 100644 index 0000000..82e0188 --- /dev/null +++ b/src/JsonTypeException.php @@ -0,0 +1,4 @@ +jsonObject) ?: ""; } - public function setJson(JsonObject $jsonObject):void { + public function setObject(JsonObject $jsonObject):void { $this->jsonObject = $jsonObject; } + /** + * Set a value in the document using dot notation for nested objects. + * + * @param string $key The key to set, can use dot notation for nested objects + * @param mixed $value The value to set + * @throws JsonTypeException If the document object is not a JsonKvpObject + */ + public function set(string $key, mixed $value):void { + $this->ensureJsonKvpObject(); + + if(!str_contains($key, ".")) { + $this->setSimpleKey($key, $value); + return; + } + + $this->setNestedKey($key, $value); + } + + /** + * Ensure that the document object is a JsonKvpObject. + * + * @throws JsonTypeException If the document object is not a JsonKvpObject + */ + private function ensureJsonKvpObject():void { + if(!$this->jsonObject) { + $this->jsonObject = new JsonKvpObject(); + } + + if(!$this->jsonObject instanceof JsonKvpObject) { + throw new JsonTypeException("Internal document object is already set as not a " . JsonKvpObject::class); + } + } + + /** + * Set a simple key-value pair in the document. + * + * @param string $key The key to set + * @param mixed $value The value to set + */ + private function setSimpleKey(string $key, mixed $value):void { + if($this->jsonObject) { + $this->jsonObject = $this->jsonObject->with($key, $value); + } + } + + /** + * Set a multi-part key in the document. + * + * @param array $keyParts The key parts + * @param mixed $value The value to set + */ + private function setNestedKey(string $key, mixed $value):void { + $keyParts = explode(".", $key); + if(!$this->jsonObject) { + return; + } + + $currentKey = array_shift($keyParts); + if($currentKey === null) { + return; + } + + $remainingKey = implode(".", $keyParts); + + // Get or create the current level object + $currentObject = $this->jsonObject->contains($currentKey) && + $this->jsonObject->get($currentKey) instanceof JsonKvpObject + ? $this->jsonObject->get($currentKey) + : new JsonKvpObject(); + + // Create a temporary document to handle the remaining key parts + $tempDoc = new JsonDocument($currentObject); + $tempDoc->set($remainingKey, $value); + + // Update the root object + $this->jsonObject = $this->jsonObject->with($currentKey, $tempDoc->jsonObject); + } + + /** + * Get a value from the document using dot notation for nested objects. + * + * @param string $key The key to get, can use dot notation for nested objects + * @return null|bool|int|float|string|JsonObject|JsonDocument The value at the specified key + */ + public function get(string $key):null|bool|int|float|string|JsonObject|JsonDocument { + if(!isset($this->jsonObject)) { + return null; + } + + if(!str_contains($key, ".")) { + return $this->getSimpleKey($key); + } + + return $this->getNestedKey($key); + } + + /** + * Get a value from a simple key in the document. + * + * @param string $key The key to get + * @return null|bool|int|float|string|JsonObject|JsonDocument The value at the specified key + */ + private function getSimpleKey(string $key):null|bool|int|float|string|JsonObject|JsonDocument { + if(!$this->jsonObject) { + return null; + } + + $value = $this->jsonObject->get($key); + + if($value instanceof JsonObject) { + // Wrap JsonObject in a JsonDocument to support nested dot notation + return new JsonDocument($value); + } + + return $this->formatReturnValue($value); + } + + /** + * Get a value from a nested key in the document using dot notation. + * + * @param string $key The key to get, using dot notation + * @return null|bool|int|float|string|JsonObject The value at the specified key + */ + private function getNestedKey(string $key):null|bool|int|float|string|JsonObject { + if(!$this->jsonObject) { + return null; + } + + $keyParts = explode(".", $key); + return $this->traverseKeyParts($keyParts, $this->jsonObject); + } + + /** + * Traverse the key parts to find the value at the specified path. + * + * @param array $keyParts The key parts to traverse + * @param JsonObject $currentObject The current object being traversed + * @return null|bool|int|float|string|JsonObject The value at the specified path + */ + private function traverseKeyParts(array $keyParts, JsonObject $currentObject):null|bool|int|float|string|JsonObject { + foreach($keyParts as $i => $part) { + if(!$currentObject instanceof JsonKvpObject || !$currentObject->contains($part)) { + return null; + } + + if($i === count($keyParts) - 1) { + // Last part, get the value + $value = $currentObject->get($part); + return $this->formatReturnValue($value); + } + + $currentObject = $currentObject->get($part); + } + + // This should never be reached, but just in case + return null; + } + + /** + * Format the return value to ensure it matches the expected return type. + * + * @param mixed $value The value to format + * @return null|bool|int|float|string|JsonObject The formatted value + */ + private function formatReturnValue(mixed $value):null|bool|int|float|string|JsonObject { + if(is_null($value)) { + return null; + } + + if(is_scalar($value)) { + return $value; + } + + if($value instanceof JsonObject) { + return $value; + } + + return null; + } } diff --git a/test/phpunit/Schema/JsonDocumentTest.php b/test/phpunit/Schema/JsonDocumentTest.php index 801c84f..30aa4d6 100644 --- a/test/phpunit/Schema/JsonDocumentTest.php +++ b/test/phpunit/Schema/JsonDocumentTest.php @@ -9,6 +9,9 @@ class JsonDocumentTest extends TestCase { public function testToString_empty():void { +// An empty response should be zero bytes, because `null` is still a datum that +// represents some kind of response. If there is no data, `null` is not +// accurate. $sut = new JsonDocument(); self::assertSame("", (string)$sut); } @@ -22,14 +25,63 @@ public function testToString_array():void { self::assertSame(json_encode($testArray), (string)$sut); } - public function testSetJson_stringPrimitive():void { + public function testSetObject_stringPrimitive():void { $jsonObject = (new JsonStringPrimitive())->withPrimitiveValue("test"); $sut = new JsonDocument(); - $sut->setJson($jsonObject); + $sut->setObject($jsonObject); self::assertSame("\"test\"", (string)$sut); } - public function testSetJson_jsonObject():void { + public function testSetObject_complexObject():void { + $builder = new JsonObjectBuilder(); + $jsonObject = $builder->fromAssociativeArray([ + "name" => "John Carmack", + "releases" => ["Shadowforge", "Catacomb", "Commander Keen", "Wolfenstein", "Doom", "Quake"], + ]); + $sut = new JsonDocument(); + $sut->setObject($jsonObject); + + self::assertSame( + '{"name":"John Carmack","releases":["Shadowforge","Catacomb","Commander Keen","Wolfenstein","Doom","Quake"]}', + (string)$sut + ); + } + + public function testGet_notSet():void { + $sut = new JsonDocument(); + self::assertNull($sut->get("nothing")); + } + + public function testSet():void { + $sut = new JsonDocument(); + $name = "John Carmack"; + $sut->set("name", $name); + self::assertSame($name, (string)$sut->get("name")); + } + + public function testSet_nested():void { + $sut = new JsonDocument(); + $sut->set("department.name", "Computer Science"); + self::assertSame("Computer Science", $sut->get("department.name")); + } + + public function testSet_nesedGet():void { + $sut = new JsonDocument(); + $sut->set("department.name", "Computer Science"); + $department = $sut->get("department"); + self::assertSame("Computer Science", $department->get("name")); + } + + public function testSet_veryNested():void { + $sut = new JsonDocument(); + $sut->set("one.two.three.four.five", "example"); + $one = $sut->get("one"); + $two = $one->get("two"); + $three = $two->get("three"); + self::assertSame("example", $three->get("four.five")); + } + + public function testConstruct_jsonObject():void { $exampleArray = [ "name" => "Test", "age" => 99,