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,