Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion phpcs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
<rule ref="Generic.Files.OneObjectStructurePerFile" />
<rule ref="Generic.Files.OneTraitPerFile" />
<rule ref="Generic.Formatting.DisallowMultipleStatements" />
<rule ref="Generic.Formatting.NoSpaceAfterCast" />
<rule ref="Generic.Functions.FunctionCallArgumentSpacing" />
<rule ref="Generic.Functions.OpeningFunctionBraceKernighanRitchie" />
<rule ref="Generic.Metrics.CyclomaticComplexity" />
Expand Down
2 changes: 1 addition & 1 deletion src/JsonKvpObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -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() ?? ""]);
}
}
130 changes: 91 additions & 39 deletions src/JsonObjectBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, mixed>|string|int|float|bool|null $jsonDecoded
* Create a JsonObject from a decoded JSON value.
*
* @param object|array<int|string, mixed>|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<int|string, mixed> $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<string, mixed> $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 {
Expand All @@ -100,5 +153,4 @@ public function fromFile(string $filePath):JsonObject {

return self::fromJsonString(file_get_contents($filePath) ?: "");
}

}
4 changes: 4 additions & 0 deletions src/JsonTypeException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
namespace Gt\Json;

class JsonTypeException extends JsonException {}
183 changes: 182 additions & 1 deletion src/Schema/JsonDocument.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<?php
namespace Gt\Json\Schema;

use Gt\Json\JsonKvpObject;
use Gt\Json\JsonObject;
use Gt\Json\JsonTypeException;

class JsonDocument {
public function __construct(
Expand All @@ -16,8 +18,187 @@ public function __toString():string {
return json_encode($this->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<int, string> $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<int, string> $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;
}
}
Loading