Merge branch 'feature/first-release' into develop

This commit is contained in:
Andrea Marco Sartori 2023-06-16 18:02:10 +02:00
commit d9c9376923
85 changed files with 5616 additions and 101 deletions

View File

@ -11,8 +11,8 @@ jobs:
strategy:
fail-fast: false
matrix:
php: [8.0, 8.1]
dependency-version: [prefer-stable]
php: [8.1, 8.2]
dependency-version: [prefer-lowest, prefer-stable]
os: [ubuntu-latest]
name: PHP ${{ matrix.php }} - ${{ matrix.dependency-version }} - ${{ matrix.os }}
@ -25,7 +25,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: json, mbstring
extensions: simdjson
tools: composer:v2
coverage: none
@ -33,7 +33,7 @@ jobs:
run: composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction
- name: Execute tests
run: vendor/bin/pest --verbose
run: vendor/bin/pest
coverage:
runs-on: ubuntu-latest
@ -49,8 +49,8 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.0
extensions: json, mbstring
php-version: 8.1
extensions: simdjson
tools: composer:v2
coverage: xdebug
@ -75,9 +75,32 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.0
php-version: 8.1
tools: phpcs
coverage: none
- name: Execute check
run: phpcs --standard=psr12 src/
static:
runs-on: ubuntu-latest
name: Static analysis
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.1
extensions: simdjson
tools: composer:v2
coverage: none
- name: Install dependencies
run: composer update --prefer-stable --prefer-dist --no-interaction
- name: Execute check
run: vendor/bin/phpstan analyse

1
.gitignore vendored
View File

@ -3,4 +3,5 @@ composer.lock
vendor
phpcs.xml
phpunit.xml
.phpunit.cache
.phpunit.result.cache

View File

@ -1,6 +1,7 @@
build:
nodes:
analysis:
image: default-bionic
project_setup:
override: true
tests:

489
README.md
View File

@ -5,12 +5,14 @@
[![Build Status][ico-actions]][link-actions]
[![Coverage Status][ico-scrutinizer]][link-scrutinizer]
[![Quality Score][ico-code-quality]][link-code-quality]
[![PHPStan Level][ico-phpstan]][link-phpstan]
[![Latest Version][ico-version]][link-packagist]
[![Software License][ico-license]](LICENSE.md)
[![PSR-7][ico-psr7]][link-psr7]
[![PSR-12][ico-psr12]][link-psr12]
[![Total Downloads][ico-downloads]][link-downloads]
Zero-dependencies pull parser and lexer to save memory while reading big JSONs.
Zero-dependencies pull parser to read large JSON from any source in a memory-efficient way.
## 📦 Install
@ -23,7 +25,484 @@ composer require cerbero/json-parser
## 🔮 Usage
work in progress... :)
* [👣 Basics](#-basics)
* [💧 Sources](#-sources)
* [🎯 Pointers](#-pointers)
* [🐼 Lazy pointers](#-lazy-pointers)
* [⚙️ Decoders](#%EF%B8%8F-decoders)
* [💢 Errors handling](#-errors-handling)
* [⏳ Progress](#-progress)
* [🛠 Settings](#-settings)
### 👣 Basics
JSON Parser provides a minimal API to read large JSON from any source:
```php
// a source is anything that can provide a JSON, in this case an endpoint
$source = 'https://randomuser.me/api/1.4?seed=json-parser&results=5';
foreach (new JsonParser($source) as $key => $value) {
// instead of loading the whole JSON, we keep in memory only one key and value at a time
}
```
Depending on our code style, we can instantiate the parser in 3 different ways:
```php
use Cerbero\JsonParser\JsonParser;
use function Cerbero\JsonParser\parseJson;
// classic object instantiation
new JsonParser($source);
// static instantiation
JsonParser::parse($source);
// namespaced function
parseJson($source);
```
If we don't want to use `foreach()` to loop through each key and value, we can chain the `traverse()` method:
```php
JsonParser::parse($source)->traverse(function (mixed $value, string|int $key, JsonParser $parser) {
// lazily load one key and value at a time, we can also access the parser if needed
});
// no foreach needed
```
> ⚠️ Please note the parameters order of the callback: the value is passed before the key.
### 💧 Sources
A JSON source is any data point that provides a JSON. A wide range of sources are supported by default:
- **strings**, e.g. `{"foo":"bar"}`
- **iterables**, i.e. arrays or instances of `Traversable`
- **file paths**, e.g. `/path/to/large.json`
- **resources**, e.g. streams
- **API endpoint URLs**, e.g. `https://endpoint.json` or any instance of `Psr\Http\Message\UriInterface`
- **PSR-7 requests**, i.e. any instance of `Psr\Http\Message\RequestInterface`
- **PSR-7 messages**, i.e. any instance of `Psr\Http\Message\MessageInterface`
- **PSR-7 streams**, i.e. any instance of `Psr\Http\Message\StreamInterface`
- **Laravel HTTP client requests**, i.e. any instance of `Illuminate\Http\Client\Request`
- **Laravel HTTP client responses**, i.e. any instance of `Illuminate\Http\Client\Response`
- **user-defined sources**, i.e. any instance of `Cerbero\JsonParser\Sources\Source`
If the source we need to parse is not supported by default, we can implement our own custom source.
<details><summary><b>Click here to see how to implement a custom source.</b></summary>
To implement a custom source, we need to extend `Source` and implement 3 methods:
```php
use Cerbero\JsonParser\Sources\Source;
use Traversable;
class CustomSource extends Source
{
public function getIterator(): Traversable
{
// return a Traversable holding the JSON source, e.g. a Generator yielding chunks of JSON
}
public function matches(): bool
{
// return TRUE if this class can handle the JSON source
}
protected function calculateSize(): ?int
{
// return the size of the JSON in bytes or NULL if it can't be calculated
}
}
```
The parent class `Source` gives us access to 2 properties:
- `$source`: the JSON source we pass to the parser, i.e.: `new JsonParser($source)`
- `$config`: the configuration we set by chaining methods like `$parser->pointer('/foo')`
The method `getIterator()` defines the logic to read the JSON source in a memory-efficient way. It feeds the parser with small pieces of JSON. Please refer to the [already existing sources](https://github.com/cerbero90/json-parser/tree/master/src/Sources) to see some implementations.
The method `matches()` determines whether the JSON source passed to the parser can be handled by our custom implementation. In other words, we are telling the parser if it should use our class for the JSON to parse.
Finally, `calculateSize()` computes the whole size of the JSON source. It's used to track the [parsing progress](#-progress), however it's not always possible to know the size of a JSON source. In this case, or if we don't need to track the progress, we can return `null`.
Now that we have implemented our custom source, we can pass it to the parser:
```php
$json = JsonParser::parse(new CustomSource($source));
foreach ($json as $key => $value) {
// process one key and value of $source at a time
}
```
If you find yourself implementing the same custom source in different projects, feel free to send a PR and we will consider to support your custom source by default. Thank you in advance for any contribution!
</details>
### 🎯 Pointers
A JSON pointer is a [standard](https://www.rfc-editor.org/rfc/rfc6901) used to point to nodes within a JSON. This package leverages JSON pointers to extract only some sub-trees from large JSONs.
Consider [this JSON](https://randomuser.me/api/1.4?seed=json-parser&results=5) for example. To extract only the first gender and avoid parsing the rest of the JSON, we can set the `/results/0/gender` pointer:
```php
$json = JsonParser::parse($source)->pointer('/results/0/gender');
foreach ($json as $key => $value) {
// 1st and only iteration: $key === 'gender', $value === 'female'
}
```
JSON Parser takes advantage of the `-` wildcard to point to any array index, so we can extract all the genders with the `/results/-/gender` pointer:
```php
$json = JsonParser::parse($source)->pointer('/results/-/gender');
foreach ($json as $key => $value) {
// 1st iteration: $key === 'gender', $value === 'female'
// 2nd iteration: $key === 'gender', $value === 'female'
// 3rd iteration: $key === 'gender', $value === 'male'
// and so on for all the objects in the array...
}
```
If we want to extract more sub-trees, we can set multiple pointers. Let's extract all genders and countries:
```php
$json = JsonParser::parse($source)->pointers(['/results/-/gender', '/results/-/location/country']);
foreach ($json as $key => $value) {
// 1st iteration: $key === 'gender', $value === 'female'
// 2nd iteration: $key === 'country', $value === 'Germany'
// 3rd iteration: $key === 'gender', $value === 'female'
// 4th iteration: $key === 'country', $value === 'Mexico'
// and so on for all the objects in the array...
}
```
> ⚠️ Intersecting pointers like `/foo` and `/foo/bar` is not allowed but intersecting wildcards like `foo/-/bar` and `foo/0/bar` is possible.
We can also specify a callback to execute when JSON pointers are found. This is handy when we have different pointers and we need to run custom logic for each of them:
```php
$json = JsonParser::parse($source)->pointers([
'/results/-/gender' => fn (string $gender, string $key) => new Gender($gender),
'/results/-/location/country' => fn (string $country, string $key) => new Country($country),
]);
foreach ($json as $key => $value) {
// 1st iteration: $key === 'gender', $value instanceof Gender
// 2nd iteration: $key === 'country', $value instanceof Country
// and so on for all the objects in the array...
}
```
> ⚠️ Please note the parameters order of the callbacks: the value is passed before the key.
The same can also be achieved by chaining the method `pointer()` multiple times:
```php
$json = JsonParser::parse($source)
->pointer('/results/-/gender', fn (string $gender, string $key) => new Gender($gender))
->pointer('/results/-/location/country', fn (string $country, string $key) => new Country($country));
foreach ($json as $key => $value) {
// 1st iteration: $key === 'gender', $value instanceof Gender
// 2nd iteration: $key === 'country', $value instanceof Country
// and so on for all the objects in the array...
}
```
Pointer callbacks can also be used to customize a key. We can achieve that by updating the key **reference**:
```php
$json = JsonParser::parse($source)->pointer('/results/-/name/first', function (string $name, string &$key) {
$key = 'first_name';
});
foreach ($json as $key => $value) {
// 1st iteration: $key === 'first_name', $value === 'Sara'
// 2nd iteration: $key === 'first_name', $value === 'Andrea'
// and so on for all the objects in the array...
}
```
If the callbacks are enough to handle the pointers and we don't need to run any common logic for all pointers, we can avoid to manually call `foreach()` by chaining the method `traverse()`:
```php
JsonParser::parse($source)
->pointer('/-/gender', $this->handleGender(...))
->pointer('/-/location/country', $this->handleCountry(...))
->traverse();
// no foreach needed
```
Otherwise if some common logic for all pointers is needed but we prefer methods chaining to manual loops, we can pass a callback to the `traverse()` method:
```php
JsonParser::parse($source)
->pointer('/results/-/gender', fn (string $gender, string $key) => new Gender($gender))
->pointer('/results/-/location/country', fn (string $country, string $key) => new Country($country))
->traverse(function (Gender|Country $value, string $key, JsonParser $parser) {
// 1st iteration: $key === 'gender', $value instanceof Gender
// 2nd iteration: $key === 'country', $value instanceof Country
// and so on for all the objects in the array...
});
// no foreach needed
```
> ⚠️ Please note the parameters order of the callbacks: the value is passed before the key.
Sometimes the sub-trees extracted by pointers are small enough to be kept entirely in memory. In such cases, we can chain `toArray()` to eager load the extracted sub-trees into an array:
```php
// ['gender' => 'female', 'country' => 'Germany']
$array = JsonParser::parse($source)->pointers(['/results/0/gender', '/results/0/location/country'])->toArray();
```
### 🐼 Lazy pointers
JSON Parser only keeps one key and one value in memory at a time. However, if the value is a large array or object, it may be inefficient to keep it all in memory.
To solve this problem, we can use lazy pointers. These pointers recursively keep in memory only one key and one value at a time for any nested array or object.
```php
$json = JsonParser::parse($source)->lazyPointer('/results/0/name');
foreach ($json as $key => $value) {
// 1st iteration: $key === 'name', $value instanceof Parser
}
```
Lazy pointers return a lightweight instance of `Cerbero\JsonParser\Tokens\Parser` instead of the actual large value. To lazy load nested keys and values, we can then loop through the parser:
```php
$json = JsonParser::parse($source)->lazyPointer('/results/0/name');
foreach ($json as $key => $value) {
// 1st iteration: $key === 'name', $value instanceof Parser
foreach ($value as $nestedKey => $nestedValue) {
// 1st iteration: $nestedKey === 'title', $nestedValue === 'Mrs'
// 2nd iteration: $nestedKey === 'first', $nestedValue === 'Sara'
// 3rd iteration: $nestedKey === 'last', $nestedValue === 'Meder'
}
}
```
As mentioned above, lazy pointers are recursive. This means that no nested objects or arrays will ever be kept in memory:
```php
$json = JsonParser::parse($source)->lazyPointer('/results/0/location');
foreach ($json as $key => $value) {
// 1st iteration: $key === 'location', $value instanceof Parser
foreach ($value as $nestedKey => $nestedValue) {
// 1st iteration: $nestedKey === 'street', $nestedValue instanceof Parser
// 2nd iteration: $nestedKey === 'city', $nestedValue === 'Sontra'
// ...
// 6th iteration: $nestedKey === 'coordinates', $nestedValue instanceof Parser
// 7th iteration: $nestedKey === 'timezone', $nestedValue instanceof Parser
}
}
```
To lazily parse the entire JSON, we can simply chain the `lazy()` method:
```php
foreach (JsonParser::parse($source)->lazy() as $key => $value) {
// 1st iteration: $key === 'results', $value instanceof Parser
// 2nd iteration: $key === 'info', $value instanceof Parser
}
```
Lazy pointers also have all the other functionalities of normal pointers: they accept callbacks, can be set one by one or all together, can be eager loaded into an array and can be mixed with normal pointers as well:
```php
// set custom callback to run only when names are found
$json = JsonParser::parse($source)->lazyPointer('/results/-/name', fn (Parser $name) => $this->handleName($name));
// set multiple lazy pointers one by one
$json = JsonParser::parse($source)
->lazyPointer('/results/-/name', fn (Parser $name) => $this->handleName($name))
->lazyPointer('/results/-/location', fn (Parser $location) => $this->handleLocation($location));
// set multiple lazy pointers all together
$json = JsonParser::parse($source)->lazyPointers([
'/results/-/name' => fn (Parser $name) => $this->handleName($name)),
'/results/-/location' => fn (Parser $location) => $this->handleLocation($location)),
]);
// eager load lazy pointers into an array
// ['name' => ['title' => 'Mrs', 'first' => 'Sara', 'last' => 'Meder'], 'street' => ['number' => 46, 'name' => 'Römerstraße']]
$array = JsonParser::parse($source)->lazyPointers(['/results/0/name', '/results/0/location/street'])->toArray();
// mix pointers and lazy pointers
$json = JsonParser::parse($source)
->pointer('/results/-/gender', fn (string $gender) => $this->handleGender($gender))
->lazyPointer('/results/-/name', fn (Parser $name) => $this->handleName($name));
```
### ⚙️ Decoders
By default JSON Parser uses the built-in PHP function `json_decode()` to decode one key and value at a time.
Normally it decodes values to associative arrays but, if we prefer to decode values to objects, we can set a custom decoder:
```php
use Cerbero\JsonParser\Decoders\JsonDecoder;
JsonParser::parse($source)->decoder(new JsonDecoder(decodesToArray: false));
```
The [simdjson extension](https://github.com/crazyxman/simdjson_php#simdjson_php) offers a decoder [faster](https://github.com/crazyxman/simdjson_php/tree/master/benchmark#run-phpbench-benchmark) than `json_decode()` that can be installed via `pecl install simdjson` if your server satisfies the [requirements](https://github.com/crazyxman/simdjson_php#requirement). JSON Parser leverages the simdjson decoder by default if the extension is loaded.
If we need a decoder that is not supported by default, we can implement our custom one.
<details><summary><b>Click here to see how to implement a custom decoder.</b></summary>
To create a custom decoder, we need to implement the `Decoder` interface and implement 1 method:
```php
use Cerbero\JsonParser\Decoders\Decoder;
use Cerbero\JsonParser\Decoders\DecodedValue;
class CustomDecoder implements Decoder
{
public function decode(string $json): DecodedValue
{
// return an instance of DecodedValue both in case of success or failure
}
}
```
The method `decode()` defines the logic to decode the given JSON value and it needs to return an instance of `DecodedValue` both in case of success or failure.
To make custom decoder implementations even easier, JSON Parser provides an [abstract decoder](https://github.com/cerbero90/json-parser/tree/master/src/Decoders/AbstractDecoder.php) that hydrates `DecodedValue` for us so that we just need to define how a JSON value should be decoded:
```php
use Cerbero\JsonParser\Decoders\AbstractDecoder;
class CustomDecoder extends AbstractDecoder
{
protected function decodeJson(string $json): mixed
{
// decode the given JSON or throw an exception on failure
return json_decode($json, flags: JSON_THROW_ON_ERROR);
}
}
```
> ⚠️ Please make sure to throw an exception in `decodeJson()` if the decoding process fails.
Now that we have implemented our custom decoder, we can set it like this:
```php
JsonParser::parse($source)->decoder(new CustomDecoder());
```
To see some implementation examples, please refer to the [already existing decoders](https://github.com/cerbero90/json-parser/tree/master/src/Decoders).
If you find yourself implementing the same custom decoder in different projects, feel free to send a PR and we will consider to support your custom decoder by default. Thank you in advance for any contribution!
</details>
### 💢 Errors handling
Not all JSONs are valid, some may present syntax errors due to an incorrect structure (e.g. `[}`) or decoding errors when values can't be decoded properly (e.g. `[1a]`). JSON Parser allows us to intervene and define the logic to run when these issues occur:
```php
use Cerbero\JsonParser\Decoders\DecodedValue;
use Cerbero\JsonParser\Exceptions\SyntaxException;
$json = JsonParser::parse($source)
->onSyntaxError(fn (SyntaxException $e) => $this->handleSyntaxError($e))
->onDecodingError(fn (DecodedValue $decoded) => $this->handleDecodingError($decoded));
```
We can even replace invalid values with placeholders to avoid that the entire JSON parsing fails because of them:
```php
// instead of failing, replace invalid values with NULL
$json = JsonParser::parse($source)->patchDecodingError();
// instead of failing, replace invalid values with '<invalid>'
$json = JsonParser::parse($source)->patchDecodingError('<invalid>');
```
For more advanced decoding errors patching, we can pass a closure that has access to the `DecodedValue` instance:
```php
use Cerbero\JsonParser\Decoders\DecodedValue;
$patches = ['1a' => 1, '2b' => 2];
$json = JsonParser::parse($source)
->patchDecodingError(fn (DecodedValue $decoded) => $patches[$decoded->json] ?? null);
```
Any exception thrown by this package implements the `JsonParserException` interface. This makes it easy to handle all exceptions in a single catch block:
```php
use Cerbero\JsonParser\Exceptions\JsonParserException;
try {
JsonParser::parse($source)->traverse();
} catch (JsonParserException) {
// handle any exception thrown by JSON Parser
}
```
For reference, here is a comprehensive table of all the exceptions thrown by this package:
|`Cerbero\JsonParser\Exceptions\`|thrown when|
|---|---|
|`DecodingException`|a value in the JSON can't be decoded|
|`GuzzleRequiredException`|Guzzle is not installed and the JSON source is an endpoint|
|`IntersectingPointersException`|two JSON pointers intersect|
|`InvalidPointerException`|a JSON pointer syntax is not valid|
|`SyntaxException`|the JSON structure is not valid|
|`UnsupportedSourceException`|a JSON source is not supported|
### ⏳ Progress
When processing large JSONs, it can be helpful to track the parsing progress. JSON Parser provides convenient methods for accessing all the progress details:
```php
$json = new JsonParser($source);
$json->progress(); // <Cerbero\JsonParser\ValueObjects\Progress>
$json->progress()->current(); // the already parsed bytes e.g. 86759341
$json->progress()->total(); // the total bytes to parse e.g. 182332642
$json->progress()->fraction(); // the completed fraction e.g. 0.47583
$json->progress()->percentage(); // the completed percentage e.g. 47.583
$json->progress()->format(); // the formatted progress e.g. 47.5%
```
The total size of a JSON is calculated differently depending on the [source](#-sources). In some cases, it may not be possible to determine the size of a JSON and only the current progress is known:
```php
$json->progress()->current(); // 86759341
$json->progress()->total(); // null
$json->progress()->fraction(); // null
$json->progress()->percentage(); // null
$json->progress()->format(); // null
```
### 🛠 Settings
JSON Parser also provides other settings to fine-tune the parsing process. For example we can set the number of bytes to read when parsing JSON strings or streams:
```php
$json = JsonParser::parse($source)->bytes(1024 * 16); // read JSON chunks of 16KB
```
## 📆 Change log
@ -55,19 +534,23 @@ The MIT License (MIT). Please see [License File](LICENSE.md) for more informatio
[ico-author]: https://img.shields.io/static/v1?label=author&message=cerbero90&color=50ABF1&logo=twitter&style=flat-square
[ico-php]: https://img.shields.io/packagist/php-v/cerbero/json-parser?color=%234F5B93&logo=php&style=flat-square
[ico-version]: https://img.shields.io/packagist/v/cerbero/json-parser.svg?label=version&style=flat-square
[ico-actions]: https://img.shields.io/github/workflow/status/cerbero90/json-parser/build?style=flat-square&logo=github
[ico-actions]: https://img.shields.io/github/actions/workflow/status/cerbero90/json-parser/build.yml?branch=master&style=flat-square&logo=github
[ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square
[ico-psr7]: https://img.shields.io/static/v1?label=compliance&message=PSR-7&color=blue&style=flat-square
[ico-psr12]: https://img.shields.io/static/v1?label=compliance&message=PSR-12&color=blue&style=flat-square
[ico-scrutinizer]: https://img.shields.io/scrutinizer/coverage/g/cerbero90/json-parser.svg?style=flat-square&logo=scrutinizer
[ico-code-quality]: https://img.shields.io/scrutinizer/g/cerbero90/json-parser.svg?style=flat-square&logo=scrutinizer
[ico-phpstan]: https://img.shields.io/badge/level-max-success?style=flat-square&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAGb0lEQVR42u1Xe1BUZRS/y4Kg8oiR3FCCBUySESZBRCiaBnmEsOzeSzsg+KxYYO9dEEftNRqZjx40FRZkTpqmOz5S2LsXlEZBciatkQnHDGYaGdFy1EpGMHl/p/PdFlt2rk5O+J9n5nA/vtf5ned3lnlISpRhafBlLRLHCtJGVrB/ZBDsaw2lUqzReGAC46DstTYfnSCGUjaaDvgxACo6j3vUenNdImeRXqdnWV5az5rrnzeZznj8J+E5Ftsclhf3s4J4CS/oRx5Bvon8ZU65FGYQxAwcf85a7CeRz+C41THejueydCZ7AAK34nwv3kHP/oUKdOL4K7258fF7Cud427O48RQeGkIGJ77N8fZqlrcfRP4d/x90WQfHXLeBt9dTrSlwl3V65ynWLM1SEA2qbNQckbe4Xmww10Hmy3shid0CMcmlEJtSDsl5VZBdfAgMvI3uuR+moJqN6LaxmpsOBeLCDmTifCB92RcQmbAUJvtqALc5sQr8p86gYBCcFdBq9wOin7NQax6ewlB6rqLZHf23FP10y3lj6uJtEBg2HxiVCtzd3SEwMBCio6Nh9uzZ4O/vLwOZ4OUNM2NyIGPFrvuzBG//lRPs+VQ2k1ki+ePkd84bskz7YFpYgizEz88P8vPzYffu3dDS0gJNTU1QXV0NqampRK1WIwgfiE4qhOyig0rC+pCvK8QUoML7uJVHA5kcQUp3DSpqWjc3d/Dy8oKioiLo6uqCoaEhuHb1KvT09AAhBFpbW4lOpyMyyIBQSCmoUQLQzgniNvz+obB2HS2RwBgE6dOxCyJogmNkP2u1Wrhw4QJ03+iGrR9XEd3CTNBn6eCbo40wPDwMdXV1BF1DVG5qiEtboxSUP6J71+D3NwUAhLOIRQzm7lnnhYUv7QFv/yDZ/Lm5ubK2DVI9iZ8bR8JDtEB57lNzENQN6OjoIGlpabIVZsYaMTO+hrikRRA1JxmSX9hE7/sJtVyF38tKsUCVZxBhz9jI3wGT/QJlADzPAyXrnj0kInzGHQCRMyOg/ed2uHjxIuE4TgYQHq2DLJqumashY+lnsMC4GVC5do6XVuK9l+4SkN8y+GfYeVJn2g++U7QygPT0dBgYGIDvT58mnF5PQcjC83PzSF9fH7S1tZGEhAQZQOT8JaA317oIkM6jS8uVLSDzOQqg23Uh+MlkOf00Gg0cP34c+vv74URzM9n41gby/rvvkc7OThlATU3NCGYJUXt4QaLuTYwBcTSOBmj1RD7D4Tsix4ByOjZRF/zgupDEbgZ3j4ly/qekpND0o5aQ44HS4OAgsVqtI1gTZO01IbG0aP1bknnxCDUvArHi+B0lJSlzglTFYO2udF3Ql9TCrHn5oEIreHp6QlRUFJSUlJCqqipSWVlJ8vLyCGYIFS7HS3zGa87mv4lcjLwLlStlLTKYYUUAlvrlDGcW45wKxXX6aqHZNutM+1oQBHFTewAKkoH4+vqCj48PYAGS5yb5amjNoO+CU2SL53NKpDD0vxHHmOJir7L5xUvZgm0us2R142ScOIyVqYvlpWU4XoHIP8DXL2b+wjdWeXh6U2FjmIIKmbWAYPFRMus62h/geIvjOQYlpuDysQrLL6Ger49HgW8jqvXUhI7UvDb9iaSTDqHtyItiF5Suw5ewF/Nd8VJ6zlhsn06bEhwX4NyfCvuGEeRpTmh4mkG68yDpyuzB9EUcjU5awbAgncPlAeSdAQER0zCndzqVbeXC4qDsMpvGEYBXRnsDx4N3Auf1FCTjTIaVtY/QTmd0I8bBVm1kejEubUfO01vqImn3c49X7qpeqI9inIgtbpxK3YrKfIJCt+OeV2nfUVFR4ca4EkVENyA7gkYcMfB1R5MMmxZ7ez/2KF5SSN1yV+158UPsJT0ZBcI2bRLtIXGoYu5FerOUiJe1OfsL3XEWH43l2KS+iJF9+S4FpcNgsc+j8cT8H4o1bfPg/qkLt50uJ1RzdMsGg0UqwfEN114Pwb1CtWTGg+Y9U5ClK9x7xUWI7BI5VQVp0AVcQ3bZkQhmnEgdHhKyNSZe16crtBIlc7sIb6cRLft2PCgoKGjijBDtjrAQ7a3EdMsxzIRflAFIhPb6mHYmYwX+WBlPQgskhgVryyJCQyNyBLsBQdQ6fgsQhyt6MSOOsWZ7gbH8wETmgRKAijatNL8Ngm0xx4tLcsps0Wzx4al0jXlI40B/A3pa144MDtSgAAAAAElFTkSuQmCC
[ico-downloads]: https://img.shields.io/packagist/dt/cerbero/json-parser.svg?style=flat-square
[link-author]: https://twitter.com/cerbero90
[link-php]: https://www.php.net
[link-packagist]: https://packagist.org/packages/cerbero/json-parser
[link-actions]: https://github.com/cerbero90/json-parser/actions?query=workflow%3Abuild
[link-psr7]: https://www.php-fig.org/psr/psr-7/
[link-psr12]: https://www.php-fig.org/psr/psr-12/
[link-scrutinizer]: https://scrutinizer-ci.com/g/cerbero90/json-parser/code-structure
[link-code-quality]: https://scrutinizer-ci.com/g/cerbero90/json-parser
[link-phpstan]: https://phpstan.org/
[link-downloads]: https://packagist.org/packages/cerbero/json-parser
[link-contributors]: ../../contributors

View File

@ -1,11 +1,13 @@
{
"name": "cerbero/json-parser",
"type": "library",
"description": "Zero-dependencies pull parser and lexer to save memory while reading big JSONs.",
"description": "Zero-dependencies pull parser to read large JSON from any source in a memory-efficient way.",
"keywords": [
"json",
"parser",
"lexer"
"json-parser",
"lexer",
"memory"
],
"homepage": "https://github.com/cerbero90/json-parser",
"license": "MIT",
@ -16,19 +18,27 @@
"role": "Developer"
}],
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"php": "^8.0"
"php": "^8.1"
},
"require-dev": {
"pestphp/pest": "^1.21",
"guzzlehttp/guzzle": "^7.2",
"illuminate/http": ">=6.20",
"mockery/mockery": "^1.5",
"pestphp/pest": "^2.0",
"phpstan/phpstan": "^1.9",
"scrutinizer/ocular": "^1.8",
"squizlabs/php_codesniffer": "^3.0"
},
"suggest": {
"guzzlehttp/guzzle": "Required to load JSON from endpoints (^7.2)."
},
"autoload": {
"psr-4": {
"Cerbero\\JsonParser\\": "src"
}
},
"files": [
"helpers.php"
]
},
"autoload-dev": {
"psr-4": {
@ -37,6 +47,7 @@
},
"scripts": {
"test": "pest",
"static": "phpstan analyze",
"check-style": "phpcs --standard=PSR12 src",
"fix-style": "phpcbf --standard=PSR12 src"
},

14
helpers.php Normal file
View File

@ -0,0 +1,14 @@
<?php
namespace Cerbero\JsonParser;
/**
* Parse the given source of JSON
*
* @param mixed $source
* @return JsonParser
*/
function parseJson(mixed $source): JsonParser
{
return new JsonParser($source);
}

0
phpstan-baseline.neon Normal file
View File

6
phpstan.neon Normal file
View File

@ -0,0 +1,6 @@
parameters:
level: max
paths:
- src
includes:
- phpstan-baseline.neon

View File

@ -1,28 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
verbose="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name="cerbero90 Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">src/</directory>
</whitelist>
</filter>
<logging>
<log type="junit" target="build/report.junit.xml"/>
<log type="coverage-html" target="build/coverage"/>
<log type="coverage-text" target="build/coverage.txt"/>
<log type="coverage-clover" target="build/logs/clover.xml"/>
</logging>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/autoload.php" backupGlobals="false" colors="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.2/phpunit.xsd" cacheDirectory=".phpunit.cache" backupStaticProperties="false">
<coverage>
<report>
<clover outputFile="build/logs/clover.xml"/>
<html outputDirectory="build/coverage"/>
<text outputFile="build/coverage.txt"/>
</report>
</coverage>
<testsuites>
<testsuite name="cerbero90 Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<logging>
<junit outputFile="build/report.junit.xml"/>
</logging>
<source>
<include>
<directory suffix=".php">src/</directory>
</include>
</source>
</phpunit>

View File

@ -0,0 +1,26 @@
<?php
namespace Cerbero\JsonParser\Concerns;
use function is_array;
use function in_array;
/**
* The trait to detect endpoints.
*
*/
trait DetectsEndpoints
{
/**
* Determine whether the given value points to an endpoint
*
* @param string $value
* @return bool
*/
protected function isEndpoint(string $value): bool
{
return is_array($url = parse_url($value))
&& in_array($url['scheme'] ?? null, ['http', 'https'])
&& isset($url['host']);
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace Cerbero\JsonParser\Concerns;
use Cerbero\JsonParser\Exceptions\GuzzleRequiredException;
use GuzzleHttp\Client;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\UriInterface;
/**
* The Guzzle-aware trait.
*
*/
trait GuzzleAware
{
/**
* Abort if Guzzle is not loaded
*
* @return void
* @throws GuzzleRequiredException
*/
protected function requireGuzzle(): void
{
if (!$this->guzzleIsInstalled()) {
throw new GuzzleRequiredException();
}
}
/**
* Determine whether Guzzle is installed
*
* @return bool
*/
protected function guzzleIsInstalled(): bool
{
return class_exists(Client::class);
}
/**
* Retrieve the JSON response of the given URL
*
* @param UriInterface|string $url
* @return ResponseInterface
*/
protected function getJson(UriInterface|string $url): ResponseInterface
{
return $this->guzzle()->get($url, [
'headers' => [
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
]);
}
/**
* Retrieve the Guzzle client
*
* @codeCoverageIgnore
* @return Client
*/
protected function guzzle(): Client
{
return new Client();
}
/**
* Retrieve the JSON response of the given request
*
* @param RequestInterface $request
* @return ResponseInterface
*/
protected function sendRequest(RequestInterface $request): ResponseInterface
{
return $this->guzzle()->sendRequest($request);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace Cerbero\JsonParser\Decoders;
use Throwable;
/**
* The abstract implementation of a JSON decoder.
*
*/
abstract class AbstractDecoder implements Decoder
{
/**
* Retrieve the decoded value of the given JSON
*
* @param string $json
* @return mixed
* @throws Throwable
*/
abstract protected function decodeJson(string $json): mixed;
/**
* Decode the given JSON.
*
* @param string $json
* @return DecodedValue
*/
public function decode(string $json): DecodedValue
{
try {
$value = $this->decodeJson($json);
} catch (Throwable $e) {
return DecodedValue::failed($e, $json);
}
return DecodedValue::succeeded($value);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace Cerbero\JsonParser\Decoders;
use Cerbero\JsonParser\Tokens\Parser;
use Cerbero\JsonParser\ValueObjects\Config;
/**
* The configurable decoder.
*
*/
final class ConfigurableDecoder
{
/**
* Instantiate the class.
*
* @param Config $config
*/
public function __construct(private readonly Config $config)
{
}
/**
* Decode the given value.
*
* @param Parser|string|int $value
* @return mixed
*/
public function decode(Parser|string|int $value): mixed
{
if (!is_string($value)) {
return $value;
}
$decoded = $this->config->decoder->decode($value);
if (!$decoded->succeeded) {
($this->config->onDecodingError)($decoded);
}
return $decoded->value;
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace Cerbero\JsonParser\Decoders;
use Throwable;
/**
* The decoded value.
*
*/
final class DecodedValue
{
/**
* Retrieve a successfully decoded value
*
* @param mixed $value
* @return self
*/
public static function succeeded(mixed $value): self
{
return new self(true, $value);
}
/**
* Retrieve a value failed to be decoded
*
* @param Throwable $e
* @param string $json
* @return self
*/
public static function failed(Throwable $e, string $json): self
{
return new self(false, null, $e->getMessage(), $e->getCode(), $e, $json);
}
/**
* Instantiate the class.
*
* @param mixed $value
*/
private function __construct(
public readonly bool $succeeded,
public mixed $value = null,
public readonly ?string $error = null,
public readonly ?int $code = null,
public readonly ?Throwable $exception = null,
public readonly ?string $json = null,
) {
}
}

18
src/Decoders/Decoder.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace Cerbero\JsonParser\Decoders;
/**
* The JSON decoder interface.
*
*/
interface Decoder
{
/**
* Decode the given JSON.
*
* @param string $json
* @return DecodedValue
*/
public function decode(string $json): DecodedValue;
}

View File

@ -0,0 +1,32 @@
<?php
namespace Cerbero\JsonParser\Decoders;
/**
* The decoder using the built-in JSON decoder.
*
*/
final class JsonDecoder extends AbstractDecoder
{
/**
* Instantiate the class.
*
* @param bool $decodesToArray
* @param int<1, max> $depth
*/
public function __construct(private readonly bool $decodesToArray = true, private readonly int $depth = 512)
{
}
/**
* Retrieve the decoded value of the given JSON
*
* @param string $json
* @return mixed
* @throws \Throwable
*/
protected function decodeJson(string $json): mixed
{
return json_decode($json, $this->decodesToArray, $this->depth, JSON_THROW_ON_ERROR);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Cerbero\JsonParser\Decoders;
/**
* The decoder using the simdjson extension.
*
*/
final class SimdjsonDecoder extends AbstractDecoder
{
/**
* Instantiate the class.
*
* @param bool $decodesToArray
* @param int $depth
*/
public function __construct(private readonly bool $decodesToArray = true, private readonly int $depth = 512)
{
}
/**
* Retrieve the decoded value of the given JSON
*
* @param string $json
* @return mixed
* @throws \Throwable
*/
protected function decodeJson(string $json): mixed
{
return simdjson_decode($json, $this->decodesToArray, $this->depth);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Cerbero\JsonParser\Exceptions;
use Cerbero\JsonParser\Decoders\DecodedValue;
use Exception;
/**
* The exception thrown when a JSON value cannot be decoded.
*
*/
final class DecodingException extends Exception implements JsonParserException
{
/**
* Instantiate the class
*
* @param DecodedValue $decoded
*/
public function __construct(public readonly DecodedValue $decoded)
{
parent::__construct('Decoding error: ' . $decoded->error, (int) $decoded->code);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace Cerbero\JsonParser\Exceptions;
use Exception;
/**
* The exception thrown when Guzzle is not installed.
*
*/
final class GuzzleRequiredException extends Exception implements JsonParserException
{
/**
* Instantiate the class.
*
*/
public function __construct()
{
parent::__construct('Guzzle is required to load JSON from endpoints');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace Cerbero\JsonParser\Exceptions;
use Cerbero\JsonParser\Pointers\Pointer;
use Exception;
/**
* The exception thrown when two JSON pointers intersect.
*
*/
class IntersectingPointersException extends Exception implements JsonParserException
{
/**
* Instantiate the class.
*
* @param Pointer $pointer1
* @param Pointer $pointer2
*/
public function __construct(public readonly Pointer $pointer1, public readonly Pointer $pointer2)
{
parent::__construct("The pointers [$pointer1] and [$pointer2] are intersecting");
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Cerbero\JsonParser\Exceptions;
use Exception;
/**
* The exception thrown when a JSON pointer syntax is not valid.
*
*/
final class InvalidPointerException extends Exception implements JsonParserException
{
/**
* Instantiate the class.
*
* @param string $pointer
*/
public function __construct(public readonly string $pointer)
{
parent::__construct("The string [$pointer] is not a valid JSON pointer");
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Cerbero\JsonParser\Exceptions;
use Throwable;
/**
* Any exception thrown by JSON Parser.
*
*/
interface JsonParserException extends Throwable
{
}

View File

@ -0,0 +1,43 @@
<?php
namespace Cerbero\JsonParser\Exceptions;
use Exception;
/**
* The exception thrown when the JSON syntax is not valid.
*
*/
final class SyntaxException extends Exception implements JsonParserException
{
/**
* The error position.
*
* @var int|null
*/
public ?int $position = null;
/**
* Instantiate the class
*
* @param string $value
*/
public function __construct(public readonly string $value)
{
parent::__construct("Syntax error: unexpected '$value'");
}
/**
* Set the error position
*
* @param int $position
* @return self
*/
public function setPosition(int $position): self
{
$this->position = $position;
$this->message .= " at position {$position}";
return $this;
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Cerbero\JsonParser\Exceptions;
use Exception;
/**
* The exception thrown when a JSON source is not supported.
*
*/
final class UnsupportedSourceException extends Exception implements JsonParserException
{
/**
* Instantiate the class.
*
* @param mixed $source
*/
public function __construct(public readonly mixed $source)
{
parent::__construct('Unable to load JSON from the provided source');
}
}

249
src/JsonParser.php Normal file
View File

@ -0,0 +1,249 @@
<?php
namespace Cerbero\JsonParser;
use Cerbero\JsonParser\Decoders\DecodedValue;
use Cerbero\JsonParser\Decoders\Decoder;
use Cerbero\JsonParser\Exceptions\SyntaxException;
use Cerbero\JsonParser\Pointers\Pointer;
use Cerbero\JsonParser\Sources\AnySource;
use Cerbero\JsonParser\Tokens\Lexer;
use Cerbero\JsonParser\Tokens\Parser;
use Cerbero\JsonParser\ValueObjects\Config;
use Cerbero\JsonParser\ValueObjects\Progress;
use Closure;
use IteratorAggregate;
use Traversable;
/**
* The JSON parser entry-point.
*
* @implements IteratorAggregate<string|int, mixed>
*/
final class JsonParser implements IteratorAggregate
{
/**
* The configuration.
*
* @var Config
*/
private readonly Config $config;
/**
* The lexer.
*
* @var Lexer
*/
private readonly Lexer $lexer;
/**
* The parser.
*
* @var Parser
*/
private readonly Parser $parser;
/**
* Instantiate the class statically
*
* @param mixed $source
* @return self
*/
public static function parse(mixed $source): self
{
return new self($source);
}
/**
* Instantiate the class.
*
* @param mixed $source
*/
public function __construct(mixed $source)
{
$this->config = new Config();
$this->lexer = new Lexer(new AnySource($source, $this->config));
$this->parser = new Parser($this->lexer->getIterator(), $this->config);
}
/**
* Retrieve the lazily iterable JSON
*
* @return Traversable<string|int, mixed>
*/
public function getIterator(): Traversable
{
try {
yield from $this->parser;
} catch (SyntaxException $e) {
$e->setPosition($this->lexer->position());
($this->config->onSyntaxError)($e);
}
}
/**
* Set the JSON pointers
*
* @param string[]|array<string, Closure> $pointers
* @return self
*/
public function pointers(array $pointers): self
{
foreach ($pointers as $pointer => $callback) {
$callback instanceof Closure ? $this->pointer($pointer, $callback) : $this->pointer($callback);
}
return $this;
}
/**
* Set a JSON pointer
*
* @param string $pointer
* @param Closure|null $callback
* @return self
*/
public function pointer(string $pointer, Closure $callback = null): self
{
$this->config->pointers->add(new Pointer($pointer, false, $callback));
return $this;
}
/**
* Set the lazy JSON pointers
*
* @param string[]|array<string, Closure> $pointers
* @return self
*/
public function lazyPointers(array $pointers): self
{
foreach ($pointers as $pointer => $callback) {
$callback instanceof Closure ? $this->lazyPointer($pointer, $callback) : $this->lazyPointer($callback);
}
return $this;
}
/**
* Set a lazy JSON pointer
*
* @param string $pointer
* @param Closure|null $callback
* @return self
*/
public function lazyPointer(string $pointer, Closure $callback = null): self
{
$this->config->pointers->add(new Pointer($pointer, true, $callback));
return $this;
}
/**
* Set a lazy JSON pointer for the whole JSON
*
* @return self
*/
public function lazy(): self
{
return $this->lazyPointer('');
}
/**
* Traverse the JSON one key and value at a time
*
* @param Closure|null $callback
* @return void
*/
public function traverse(Closure $callback = null): void
{
foreach ($this as $key => $value) {
$callback && $callback($value, $key, $this);
}
}
/**
* Eager load the JSON into an array
*
* @return array<string|int, mixed>
*/
public function toArray(): array
{
return $this->parser->toArray();
}
/**
* Set the JSON decoder
*
* @param Decoder $decoder
* @return self
*/
public function decoder(Decoder $decoder): self
{
$this->config->decoder = $decoder;
return $this;
}
/**
* Retrieve the parsing progress
*
* @return Progress
*/
public function progress(): Progress
{
return $this->lexer->progress();
}
/**
* The number of bytes to read in each chunk
*
* @param int<1, max> $bytes
* @return self
*/
public function bytes(int $bytes): self
{
$this->config->bytes = $bytes;
return $this;
}
/**
* Set the patch to apply during a decoding error
*
* @param mixed $patch
* @return self
*/
public function patchDecodingError(mixed $patch = null): self
{
return $this->onDecodingError(function (DecodedValue $decoded) use ($patch) {
$decoded->value = is_callable($patch) ? $patch($decoded) : $patch;
});
}
/**
* Set the logic to run during a decoding error
*
* @param Closure $callback
* @return self
*/
public function onDecodingError(Closure $callback): self
{
$this->config->onDecodingError = $callback;
return $this;
}
/**
* Set the logic to run during a syntax error
*
* @param Closure $callback
* @return self
*/
public function onSyntaxError(Closure $callback): self
{
$this->config->onSyntaxError = $callback;
return $this;
}
}

143
src/Pointers/Pointer.php Normal file
View File

@ -0,0 +1,143 @@
<?php
namespace Cerbero\JsonParser\Pointers;
use Cerbero\JsonParser\Exceptions\InvalidPointerException;
use Cerbero\JsonParser\ValueObjects\Tree;
use Closure;
use Stringable;
use function count;
use function is_int;
use function array_slice;
/**
* The JSON pointer.
*
*/
final class Pointer implements Stringable
{
/**
* The reference tokens.
*
* @var string[]
*/
public readonly array $referenceTokens;
/**
* The pointer depth.
*
* @var int
*/
public readonly int $depth;
/**
* Whether the pointer was found.
*
* @var bool
*/
public bool $wasFound = false;
/**
* Instantiate the class.
*
* @param string $pointer
* @param bool $isLazy
* @param Closure|null $callback
*/
public function __construct(
private readonly string $pointer,
public readonly bool $isLazy = false,
private readonly ?Closure $callback = null,
) {
$this->referenceTokens = $this->toReferenceTokens();
$this->depth = count($this->referenceTokens);
}
/**
* Turn the JSON pointer into reference tokens
*
* @return string[]
*/
private function toReferenceTokens(): array
{
if (preg_match('#^(?:/(?:(?:[^/~])|(?:~[01]))*)*$#', $this->pointer) === 0) {
throw new InvalidPointerException($this->pointer);
}
$tokens = explode('/', $this->pointer);
$referenceTokens = array_map(fn (string $token) => str_replace(['~1', '~0'], ['/', '~'], $token), $tokens);
return array_slice($referenceTokens, 1);
}
/**
* Call the pointer callback
*
* @param mixed $value
* @param mixed $key
* @return mixed
*/
public function call(mixed $value, mixed &$key): mixed
{
if ($this->callback === null) {
return $value;
}
return ($this->callback)($value, $key) ?? $value;
}
/**
* Determine whether the reference token at the given depth matches the provided key
*
* @param int $depth
* @param string|int $key
* @return bool
*/
public function depthMatchesKey(int $depth, string|int $key): bool
{
$referenceToken = $this->referenceTokens[$depth] ?? null;
return $referenceToken === (string) $key
|| (is_int($key) && $referenceToken === '-');
}
/**
* Determine whether the pointer matches the given tree
*
* @param Tree $tree
* @return bool
*/
public function matchesTree(Tree $tree): bool
{
return $this->referenceTokens == []
|| $this->referenceTokens == $tree->original()
|| $this->referenceTokens == $tree->wildcarded();
}
/**
* Determine whether the pointer includes the given tree
*
* @param Tree $tree
* @return bool
*/
public function includesTree(Tree $tree): bool
{
if ($this->pointer == '') {
return true;
}
return is_int($firstNest = array_search('-', $this->referenceTokens))
&& array_slice($this->referenceTokens, 0, $firstNest) === array_slice($tree->original(), 0, $firstNest);
}
/**
* Retrieve the underlying JSON pointer
*
* @return string
*/
public function __toString(): string
{
return $this->pointer;
}
}

121
src/Pointers/Pointers.php Normal file
View File

@ -0,0 +1,121 @@
<?php
namespace Cerbero\JsonParser\Pointers;
use Cerbero\JsonParser\Exceptions\IntersectingPointersException;
use Cerbero\JsonParser\ValueObjects\Tree;
use function count;
/**
* The JSON pointers aggregate.
*
*/
final class Pointers
{
/**
* The JSON pointers.
*
* @var Pointer[]
*/
private array $pointers = [];
/**
* The JSON pointer matching with the current tree.
*
* @var Pointer
*/
private Pointer $matching;
/**
* The list of pointers that were found within the JSON.
*
* @var array<string, bool>
*/
private array $found = [];
/**
* Add the given pointer
*
* @param Pointer $pointer
*/
public function add(Pointer $pointer): void
{
foreach ($this->pointers as $existingPointer) {
if (str_starts_with($existingPointer, "$pointer/") || str_starts_with($pointer, "$existingPointer/")) {
throw new IntersectingPointersException($existingPointer, $pointer);
}
}
$this->pointers[] = $pointer;
}
/**
* Retrieve the pointer matching the current tree
*
* @return Pointer
*/
public function matching(): Pointer
{
return $this->matching ??= $this->pointers[0] ?? new Pointer('');
}
/**
* Retrieve the pointer matching the given tree
*
* @param Tree $tree
* @return Pointer
*/
public function matchTree(Tree $tree): Pointer
{
if (count($this->pointers) < 2) {
return $this->matching;
}
$pointers = [];
$originalTree = $tree->original();
foreach ($this->pointers as $pointer) {
if ($pointer->referenceTokens == $originalTree) {
return $this->matching = $pointer;
}
foreach ($originalTree as $depth => $key) {
if (!$pointer->depthMatchesKey($depth, $key)) {
continue 2;
} elseif (!isset($pointers[$depth])) {
$pointers[$depth] = $pointer;
}
}
}
return $this->matching = end($pointers) ?: $this->matching;
}
/**
* Mark the given pointer as found
*
* @return Pointer
*/
public function markAsFound(): Pointer
{
if (!$this->matching->wasFound) {
$this->found[(string) $this->matching] = $this->matching->wasFound = true;
}
return $this->matching;
}
/**
* Determine whether all pointers were found in the given tree
*
* @param Tree $tree
* @return bool
*/
public function wereFoundInTree(Tree $tree): bool
{
return count($this->pointers) == count($this->found)
&& !empty($this->pointers)
&& !$this->matching->includesTree($tree);
}
}

View File

@ -1,32 +0,0 @@
<?php
namespace Cerbero\JsonParser\Providers;
use Illuminate\Support\ServiceProvider;
/**
* The service provider.
*
*/
class JsonParserServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}
/**
* Execute logic after the service provider is booted.
*
* @return void
*/
public function boot()
{
//
}
}

103
src/Sources/AnySource.php Normal file
View File

@ -0,0 +1,103 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Cerbero\JsonParser\Exceptions\UnsupportedSourceException;
use Generator;
use Traversable;
/**
* The handler of any JSON source.
*
*/
class AnySource extends Source
{
/**
* The supported sources.
*
* @var class-string<Source>[]
*/
protected array $supportedSources = [
CustomSource::class,
Endpoint::class,
Filename::class,
IterableSource::class,
Json::class,
JsonResource::class,
LaravelClientResponse::class,
Psr7Message::class,
Psr7Request::class,
Psr7Stream::class,
];
/**
* The matching source.
*
* @var Source|null
*/
protected ?Source $matchingSource;
/**
* Retrieve the JSON fragments
*
* @return Traversable<int, string>
* @throws UnsupportedSourceException
*/
public function getIterator(): Traversable
{
return $this->matchingSource();
}
/**
* Retrieve the matching source
*
* @return Source
* @throws UnsupportedSourceException
*/
protected function matchingSource(): Source
{
if (isset($this->matchingSource)) {
return $this->matchingSource;
}
foreach ($this->sources() as $source) {
if ($source->matches()) {
return $this->matchingSource = $source;
}
}
throw new UnsupportedSourceException($this->source);
}
/**
* Retrieve all available sources
*
* @return Generator<int, Source>
*/
protected function sources(): Generator
{
foreach ($this->supportedSources as $source) {
yield new $source($this->source, $this->config);
}
}
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
public function matches(): bool
{
return true;
}
/**
* Retrieve the calculated size of the JSON source
*
* @return int|null
*/
protected function calculateSize(): ?int
{
return $this->matchingSource()->size();
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Traversable;
/**
* The custom source.
*
* @property-read Source $source
*/
class CustomSource extends Source
{
/**
* Retrieve the JSON fragments
*
* @return Traversable<int, string>
*/
public function getIterator(): Traversable
{
yield from $this->source;
}
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
public function matches(): bool
{
return $this->source instanceof Source;
}
/**
* Retrieve the calculated size of the JSON source
*
* @return int|null
*/
protected function calculateSize(): ?int
{
return $this->source->size();
}
}

85
src/Sources/Endpoint.php Normal file
View File

@ -0,0 +1,85 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Cerbero\JsonParser\Concerns\DetectsEndpoints;
use Cerbero\JsonParser\Concerns\GuzzleAware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use Traversable;
use function is_string;
/**
* The endpoint source.
*
* @property-read UriInterface|string $source
*/
class Endpoint extends Source
{
use DetectsEndpoints;
use GuzzleAware;
/**
* The endpoint response.
*
* @var ResponseInterface|null
*/
protected ?ResponseInterface $response;
/**
* Retrieve the JSON fragments
*
* @return Traversable<int, string>
* @throws \Cerbero\JsonParser\Exceptions\GuzzleRequiredException
*/
public function getIterator(): Traversable
{
return new Psr7Message($this->response(), $this->config);
}
/**
* Retrieve the endpoint response
*
* @return ResponseInterface
* @throws \Cerbero\JsonParser\Exceptions\GuzzleRequiredException
*/
protected function response(): ResponseInterface
{
$this->requireGuzzle();
return $this->response ??= $this->fetchResponse();
}
/**
* Retrieve the fetched HTTP response
*
* @return ResponseInterface
*/
protected function fetchResponse(): ResponseInterface
{
return $this->getJson($this->source);
}
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
public function matches(): bool
{
/** @phpstan-ignore-next-line */
return (is_string($this->source) || $this->source instanceof UriInterface) && $this->isEndpoint($this->source);
}
/**
* Retrieve the calculated size of the JSON source
*
* @return int|null
* @throws \Cerbero\JsonParser\Exceptions\GuzzleRequiredException
*/
protected function calculateSize(): ?int
{
return $this->response()->getBody()->getSize();
}
}

51
src/Sources/Filename.php Normal file
View File

@ -0,0 +1,51 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Traversable;
use function is_string;
/**
* The filename source.
*
* @property-read string $source
*/
class Filename extends Source
{
/**
* Retrieve the JSON fragments
*
* @return Traversable<int, string>
*/
public function getIterator(): Traversable
{
$handle = fopen($this->source, 'rb');
try {
yield from new JsonResource($handle, $this->config);
} finally {
$handle && fclose($handle);
}
}
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
public function matches(): bool
{
return is_string($this->source) && is_file($this->source);
}
/**
* Retrieve the calculated size of the JSON source
*
* @return int|null
*/
protected function calculateSize(): ?int
{
return filesize($this->source) ?: null;
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Traversable;
use function is_array;
use function count;
/**
* The iterable source.
*
* @property-read iterable $source
*/
class IterableSource extends Source
{
/**
* Retrieve the JSON fragments
*
* @return Traversable<int, string>
*/
public function getIterator(): Traversable
{
yield from $this->source;
}
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
public function matches(): bool
{
return is_iterable($this->source) && !$this->source instanceof Source;
}
/**
* Retrieve the calculated size of the JSON source
*
* @return int|null
*/
protected function calculateSize(): ?int
{
return is_array($this->source) ? count($this->source) : iterator_count(clone $this->source);
}
}

51
src/Sources/Json.php Normal file
View File

@ -0,0 +1,51 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Cerbero\JsonParser\Concerns\DetectsEndpoints;
use Traversable;
use function is_string;
use function strlen;
/**
* The JSON source.
*
* @property-read string $source
*/
class Json extends Source
{
use DetectsEndpoints;
/**
* Retrieve the JSON fragments
*
* @return Traversable<int, string>
*/
public function getIterator(): Traversable
{
for ($i = 0; $i < $this->size(); $i += $this->config->bytes) {
yield substr($this->source, $i, $this->config->bytes);
}
}
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
public function matches(): bool
{
return is_string($this->source) && !is_file($this->source) && !$this->isEndpoint($this->source);
}
/**
* Retrieve the calculated size of the JSON source
*
* @return int|null
*/
protected function calculateSize(): ?int
{
return strlen($this->source);
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Traversable;
use function is_string;
use function is_resource;
/**
* The resource source.
*
* @property-read resource $source
*/
class JsonResource extends Source
{
/**
* Retrieve the JSON fragments
*
* @return Traversable<int, string>
*/
public function getIterator(): Traversable
{
while (!feof($this->source)) {
if (is_string($chunk = fread($this->source, $this->config->bytes))) {
yield $chunk;
}
}
}
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
public function matches(): bool
{
return is_resource($this->source);
}
/**
* Retrieve the calculated size of the JSON source
*
* @return int|null
*/
protected function calculateSize(): ?int
{
$stats = fstat($this->source);
$size = $stats['size'] ?? null;
return $size ?: null;
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Illuminate\Http\Client\Request;
use Psr\Http\Message\ResponseInterface;
/**
* The Laravel client request source.
*
* @property-read Request $source
*/
class LaravelClientRequest extends Psr7Request
{
/**
* Retrieve the fetched HTTP response
*
* @return ResponseInterface
*/
protected function fetchResponse(): ResponseInterface
{
return $this->sendRequest($this->source->toPsrRequest());
}
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
public function matches(): bool
{
return $this->source instanceof Request;
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Illuminate\Http\Client\Response;
use Traversable;
/**
* The Laravel client response source.
*
* @property-read Response $source
*/
class LaravelClientResponse extends Source
{
/**
* Retrieve the JSON fragments
*
* @return Traversable<int, string>
*/
public function getIterator(): Traversable
{
return new Psr7Message($this->source->toPsrResponse(), $this->config);
}
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
public function matches(): bool
{
return $this->source instanceof Response;
}
/**
* Retrieve the calculated size of the JSON source
*
* @return int|null
*/
protected function calculateSize(): ?int
{
return $this->source->toPsrResponse()->getBody()->getSize();
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\RequestInterface;
use Traversable;
/**
* The PSR-7 message source.
*
* @property-read MessageInterface $source
*/
class Psr7Message extends Source
{
/**
* Retrieve the JSON fragments
*
* @return Traversable<int, string>
*/
public function getIterator(): Traversable
{
return new Psr7Stream($this->source->getBody(), $this->config);
}
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
public function matches(): bool
{
return $this->source instanceof MessageInterface && !$this->source instanceof RequestInterface;
}
/**
* Retrieve the calculated size of the JSON source
*
* @return int|null
*/
protected function calculateSize(): ?int
{
return $this->source->getBody()->getSize();
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* The PSR-7 request source.
*
* @property-read RequestInterface $source
*/
class Psr7Request extends Endpoint
{
/**
* Retrieve the fetched HTTP response
*
* @return ResponseInterface
*/
protected function fetchResponse(): ResponseInterface
{
return $this->sendRequest($this->source);
}
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
public function matches(): bool
{
return $this->source instanceof RequestInterface;
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Psr\Http\Message\StreamInterface;
use Traversable;
use function in_array;
/**
* The PSR-7 stream source.
*
* @property-read StreamInterface $source
*/
class Psr7Stream extends Source
{
/**
* Retrieve the JSON fragments
*
* @return Traversable<int, string>
*/
public function getIterator(): Traversable
{
if (!in_array(StreamWrapper::NAME, stream_get_wrappers())) {
stream_wrapper_register(StreamWrapper::NAME, StreamWrapper::class);
}
$stream = fopen(StreamWrapper::NAME . '://stream', 'rb', false, stream_context_create([
StreamWrapper::NAME => ['stream' => $this->source],
]));
return new JsonResource($stream, $this->config);
}
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
public function matches(): bool
{
return $this->source instanceof StreamInterface;
}
/**
* Retrieve the calculated size of the JSON source
*
* @return int|null
*/
protected function calculateSize(): ?int
{
return $this->source->getSize();
}
}

78
src/Sources/Source.php Normal file
View File

@ -0,0 +1,78 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Cerbero\JsonParser\ValueObjects\Config;
use IteratorAggregate;
use Traversable;
/**
* The JSON source.
*
* @implements IteratorAggregate<int, string>
*/
abstract class Source implements IteratorAggregate
{
/**
* The cached size of the JSON source.
*
* @var int|null
*/
protected ?int $size;
/**
* Whether the JSON size has already been calculated.
* Avoid re-calculations when the size is NULL (not computable).
*
* @var bool
*/
protected bool $sizeWasSet = false;
/**
* Retrieve the JSON fragments
*
* @return Traversable<int, string>
*/
abstract public function getIterator(): Traversable;
/**
* Determine whether the JSON source can be handled
*
* @return bool
*/
abstract public function matches(): bool;
/**
* Retrieve the calculated size of the JSON source
*
* @return int|null
*/
abstract protected function calculateSize(): ?int;
/**
* Instantiate the class.
*
* @param mixed $source
* @param Config $config
*/
final public function __construct(
protected readonly mixed $source,
protected readonly Config $config = new Config(),
) {
}
/**
* Retrieve the size of the JSON source and cache it
*
* @return int|null
*/
public function size(): ?int
{
if (!$this->sizeWasSet) {
$this->size = $this->calculateSize();
$this->sizeWasSet = true;
}
return $this->size;
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace Cerbero\JsonParser\Sources;
use Psr\Http\Message\StreamInterface;
/**
* The JSON stream wrapper.
*
* @codeCoverageIgnore
* @phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps
*/
final class StreamWrapper
{
/**
* The name of the stream wrapper.
*
* @var string
*/
public const NAME = 'cerbero-json-parser';
/**
* The stream context.
*
* @var resource
*/
public mixed $context;
/**
* The PSR-7 stream.
*
* @var StreamInterface
*/
private $stream;
/**
* Open the stream
*
* @param string $path
* @param string $mode
* @param int $options
* @param mixed $opened_path
* @return bool
*
* @scrutinizer ignore-unused
*/
public function stream_open(string $path, string $mode, int $options, &$opened_path): bool
{
$options = stream_context_get_options($this->context);
$this->stream = $options[self::NAME]['stream'] ?? null;
return $this->stream instanceof StreamInterface && $this->stream->isReadable();
}
/**
* Determine whether the pointer is at the end of the stream
*
* @return bool
*/
public function stream_eof(): bool
{
return $this->stream->eof();
}
/**
* Read from the stream
*
* @param int $count
* @return string
*/
public function stream_read(int $count): string
{
return $this->stream->read($count);
}
}

23
src/Tokens/Colon.php Normal file
View File

@ -0,0 +1,23 @@
<?php
namespace Cerbero\JsonParser\Tokens;
use Cerbero\JsonParser\ValueObjects\State;
/**
* The colon token.
*
*/
final class Colon extends Token
{
/**
* Mutate the given state
*
* @param State $state
* @return void
*/
public function mutateState(State $state): void
{
$state->expectedToken = Tokens::VALUE_ANY;
}
}

24
src/Tokens/Comma.php Normal file
View File

@ -0,0 +1,24 @@
<?php
namespace Cerbero\JsonParser\Tokens;
use Cerbero\JsonParser\ValueObjects\State;
/**
* The comma token.
*
*/
final class Comma extends Token
{
/**
* Mutate the given state
*
* @param State $state
* @return void
*/
public function mutateState(State $state): void
{
$state->expectsKey = $state->tree->inObject();
$state->expectedToken = $state->expectsKey ? Tokens::SCALAR_STRING : Tokens::VALUE_ANY;
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace Cerbero\JsonParser\Tokens;
use Cerbero\JsonParser\ValueObjects\State;
/**
* The token that begins compound data (JSON arrays or objects).
*
*/
final class CompoundBegin extends Token
{
/**
* Whether this compound should be lazy loaded.
*
* @var bool
*/
public bool $shouldLazyLoad = false;
/**
* Mutate the given state
*
* @param State $state
* @return void
*/
public function mutateState(State $state): void
{
if ($this->shouldLazyLoad = $this->shouldLazyLoad && $state->tree->depth() >= 0) {
$state->expectedToken = $state->tree->inObject() ? Tokens::AFTER_OBJECT_VALUE : Tokens::AFTER_ARRAY_VALUE;
return;
}
$state->expectsKey = $beginsObject = $this->value == '{';
$state->expectedToken = $beginsObject ? Tokens::AFTER_OBJECT_BEGIN : Tokens::AFTER_ARRAY_BEGIN;
$state->tree->deepen($beginsObject);
}
/**
* Set the token value
*
* @param string $value
* @return static
*/
public function setValue(string $value): static
{
$this->shouldLazyLoad = false;
return parent::setValue($value);
}
/**
* Determine whether this token ends a JSON chunk
*
* @return bool
*/
public function endsChunk(): bool
{
return $this->shouldLazyLoad;
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Cerbero\JsonParser\Tokens;
use Cerbero\JsonParser\ValueObjects\State;
/**
* The token that ends compound data (JSON arrays or objects).
*
*/
final class CompoundEnd extends Token
{
/**
* Mutate the given state
*
* @param State $state
* @return void
*/
public function mutateState(State $state): void
{
$state->tree->emerge();
$state->expectedToken = $state->tree->inObject() ? Tokens::AFTER_OBJECT_VALUE : Tokens::AFTER_ARRAY_VALUE;
}
/**
* Determine whether this token ends a JSON chunk
*
* @return bool
*/
public function endsChunk(): bool
{
return true;
}
}

33
src/Tokens/Constant.php Normal file
View File

@ -0,0 +1,33 @@
<?php
namespace Cerbero\JsonParser\Tokens;
use Cerbero\JsonParser\ValueObjects\State;
/**
* The constant token.
*
*/
final class Constant extends Token
{
/**
* Mutate the given state
*
* @param State $state
* @return void
*/
public function mutateState(State $state): void
{
$state->expectedToken = $state->tree->inObject() ? Tokens::AFTER_OBJECT_VALUE : Tokens::AFTER_ARRAY_VALUE;
}
/**
* Determine whether this token ends a JSON chunk
*
* @return bool
*/
public function endsChunk(): bool
{
return true;
}
}

101
src/Tokens/Lexer.php Normal file
View File

@ -0,0 +1,101 @@
<?php
namespace Cerbero\JsonParser\Tokens;
use Cerbero\JsonParser\Exceptions\SyntaxException;
use Cerbero\JsonParser\Sources\Source;
use Cerbero\JsonParser\Tokens\Token;
use Cerbero\JsonParser\Tokens\Tokenizer;
use Cerbero\JsonParser\Tokens\Tokens;
use Cerbero\JsonParser\ValueObjects\Progress;
use IteratorAggregate;
use Traversable;
use function strlen;
/**
* The JSON lexer.
*
* @implements IteratorAggregate<int, Token>
*/
final class Lexer implements IteratorAggregate
{
/**
* The parsing progress.
*
* @var Progress
*/
private readonly Progress $progress;
/**
* The current position.
*
* @var int
*/
private int $position = 0;
/**
* Instantiate the class.
*
* @param Source $source
*/
public function __construct(private readonly Source $source)
{
$this->progress = new Progress();
}
/**
* Retrieve the JSON fragments
*
* @return \Generator<int, Token>
*/
public function getIterator(): Traversable
{
$buffer = '';
$inString = $isEscaping = false;
$tokenizer = Tokenizer::instance();
foreach ($this->source as $chunk) {
for ($i = 0, $size = strlen($chunk); $i < $size; $i++, $this->position++) {
$character = $chunk[$i];
$inString = ($character == '"') != $inString || $isEscaping;
$isEscaping = $character == '\\' && !$isEscaping;
if ($inString || !isset(Tokens::BOUNDARIES[$character])) {
$buffer == '' && !isset(Tokens::TYPES[$character]) && throw new SyntaxException($character);
$buffer .= $character;
continue;
}
if ($buffer != '') {
yield $tokenizer->toToken($buffer);
$buffer = '';
}
if (isset(Tokens::DELIMITERS[$character])) {
yield $tokenizer->toToken($character);
}
}
}
}
/**
* Retrieve the current position
*
* @return int
*/
public function position(): int
{
return $this->position;
}
/**
* Retrieve the parsing progress
*
* @return Progress
*/
public function progress(): Progress
{
return $this->progress->setCurrent($this->position)->setTotal($this->source->size());
}
}

141
src/Tokens/Parser.php Normal file
View File

@ -0,0 +1,141 @@
<?php
namespace Cerbero\JsonParser\Tokens;
use Cerbero\JsonParser\Decoders\ConfigurableDecoder;
use Cerbero\JsonParser\Exceptions\SyntaxException;
use Cerbero\JsonParser\Tokens\CompoundBegin;
use Cerbero\JsonParser\Tokens\CompoundEnd;
use Cerbero\JsonParser\Tokens\Token;
use Cerbero\JsonParser\ValueObjects\Config;
use Cerbero\JsonParser\ValueObjects\State;
use Generator;
use IteratorAggregate;
use Traversable;
/**
* The JSON parser.
*
* @implements IteratorAggregate<string|int, mixed>
*/
final class Parser implements IteratorAggregate
{
/**
* The decoder handling potential errors.
*
* @var ConfigurableDecoder
*/
private readonly ConfigurableDecoder $decoder;
/**
* Whether the parser is fast-forwarding.
*
* @var bool
*/
private bool $isFastForwarding = false;
/**
* Instantiate the class.
*
* @param Generator<int, Token> $tokens
* @param Config $config
*/
public function __construct(private readonly Generator $tokens, private readonly Config $config)
{
$this->decoder = new ConfigurableDecoder($config);
}
/**
* Retrieve the JSON fragments
*
* @return Traversable<string|int, mixed>
*/
public function getIterator(): Traversable
{
$state = new State($this->config->pointers, fn () => new self($this->lazyLoad(), clone $this->config));
foreach ($this->tokens as $token) {
if ($this->isFastForwarding) {
continue;
} elseif (!$token->matches($state->expectedToken)) {
throw new SyntaxException($token);
}
$state->mutateByToken($token);
if (!$token->endsChunk() || $state->tree->isDeep()) {
continue;
}
if ($state->hasBuffer()) {
/** @var string|int $key */
$key = $this->decoder->decode($state->tree->currentKey());
$value = $this->decoder->decode($state->value());
yield $key => $state->callPointer($value, $key);
$value instanceof self && $value->fastForward();
}
if ($state->canStopParsing()) {
break;
}
}
}
/**
* Retrieve the generator to lazy load the current compound
*
* @return Generator<int, Token>
*/
public function lazyLoad(): Generator
{
$depth = 0;
do {
yield $token = $this->tokens->current();
if ($token instanceof CompoundBegin) {
$depth++;
} elseif ($token instanceof CompoundEnd) {
$depth--;
}
$depth > 0 && $this->tokens->next();
} while ($depth > 0);
}
/**
* Eager load the current compound into an array
*
* @return array<string|int, mixed>
*/
public function toArray(): array
{
$array = [];
foreach ($this as $key => $value) {
$array[$key] = $value instanceof self ? $value->toArray() : $value;
}
return $array;
}
/**
* Fast-forward the parser
*
* @return void
*/
public function fastForward(): void
{
if (!$this->tokens->valid()) {
return;
}
$this->isFastForwarding = true;
foreach ($this as $value) {
$value instanceof self && $value->fastForward(); // @codeCoverageIgnore
}
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace Cerbero\JsonParser\Tokens;
use Cerbero\JsonParser\ValueObjects\State;
/**
* The scalar string token.
*
*/
final class ScalarString extends Token
{
/**
* Whether this token is an object key.
*
* @var bool
*/
private bool $isKey = false;
/**
* Mutate the given state
*
* @param State $state
* @return void
*/
public function mutateState(State $state): void
{
if ($this->isKey = $state->expectsKey) {
$state->expectsKey = false;
$state->expectedToken = Tokens::COLON;
return;
}
$state->expectedToken = $state->tree->inObject() ? Tokens::AFTER_OBJECT_VALUE : Tokens::AFTER_ARRAY_VALUE;
}
/**
* Determine whether this token ends a JSON chunk
*
* @return bool
*/
public function endsChunk(): bool
{
return !$this->isKey;
}
}

82
src/Tokens/Token.php Normal file
View File

@ -0,0 +1,82 @@
<?php
namespace Cerbero\JsonParser\Tokens;
use Cerbero\JsonParser\ValueObjects\State;
use Stringable;
/**
* The abstract implementation of a token.
*
*/
abstract class Token implements Stringable
{
/**
* The token value.
*
* @var string
*/
protected string $value;
/**
* Mutate the given state
*
* @param State $state
* @return void
*/
abstract public function mutateState(State $state): void;
/**
* Determine whether this token matches the given type
*
* @param int $type
* @return bool
*/
public function matches(int $type): bool
{
return (Tokens::TYPES[$this->value[0]] & $type) != 0;
}
/**
* Set the token value
*
* @param string $value
* @return static
*/
public function setValue(string $value): static
{
$this->value = $value;
return $this;
}
/**
* Determine whether the token is a value
*
* @return bool
*/
public function isValue(): bool
{
return (Tokens::TYPES[$this->value[0]] | Tokens::VALUE_ANY) == Tokens::VALUE_ANY;
}
/**
* Determine whether this token ends a JSON chunk
*
* @return bool
*/
public function endsChunk(): bool
{
return false;
}
/**
* Retrieve the underlying token value
*
* @return string
*/
public function __toString(): string
{
return $this->value;
}
}

70
src/Tokens/Tokenizer.php Normal file
View File

@ -0,0 +1,70 @@
<?php
namespace Cerbero\JsonParser\Tokens;
/**
* The tokenizer.
*
*/
final class Tokenizer
{
/**
* The singleton instance.
*
* @var self
*/
private static self $instance;
/**
* The map of token instances by type.
*
* @var array<int, Token>
*/
private array $tokensMap = [];
/**
* Retrieve the singleton instance
*
* @return self
*/
public static function instance(): self
{
return self::$instance ??= new self();
}
/**
* Instantiate the class.
*
*/
private function __construct()
{
$this->setTokensMap();
}
/**
* Set the tokens map
*
* @return void
*/
private function setTokensMap(): void
{
$instances = [];
foreach (Tokens::MAP as $type => $class) {
$this->tokensMap[$type] = $instances[$class] ??= new $class();
}
}
/**
* Turn the given value into a token
*
* @param string $value
* @return Token
*/
public function toToken(string $value): Token
{
$type = Tokens::TYPES[$value[0]];
return $this->tokensMap[$type]->setValue($value);
}
}

114
src/Tokens/Tokens.php Normal file
View File

@ -0,0 +1,114 @@
<?php
namespace Cerbero\JsonParser\Tokens;
/**
* The tokens related information.
*
*/
final class Tokens
{
public const SCALAR_CONST = 1 << 0;
public const SCALAR_STRING = 1 << 1;
public const OBJECT_BEGIN = 1 << 2;
public const OBJECT_END = 1 << 3;
public const ARRAY_BEGIN = 1 << 4;
public const ARRAY_END = 1 << 5;
public const COMMA = 1 << 6;
public const COLON = 1 << 7;
public const COMPOUND_BEGIN = self::OBJECT_BEGIN | self::ARRAY_BEGIN;
public const COMPOUND_END = self::OBJECT_END | self::ARRAY_END;
public const VALUE_SCALAR = self::SCALAR_CONST | self::SCALAR_STRING;
public const VALUE_ANY = self::COMPOUND_BEGIN | self::VALUE_SCALAR;
public const AFTER_ARRAY_BEGIN = self::VALUE_ANY | self::ARRAY_END;
public const AFTER_ARRAY_VALUE = self::COMMA | self::ARRAY_END;
public const AFTER_OBJECT_BEGIN = self::SCALAR_STRING | self::OBJECT_END;
public const AFTER_OBJECT_VALUE = self::COMMA | self::OBJECT_END;
/**
* The token types.
*
* @var array<string|int, int>
*/
public const TYPES = [
'n' => self::SCALAR_CONST,
't' => self::SCALAR_CONST,
'f' => self::SCALAR_CONST,
'-' => self::SCALAR_CONST,
'0' => self::SCALAR_CONST,
'1' => self::SCALAR_CONST,
'2' => self::SCALAR_CONST,
'3' => self::SCALAR_CONST,
'4' => self::SCALAR_CONST,
'5' => self::SCALAR_CONST,
'6' => self::SCALAR_CONST,
'7' => self::SCALAR_CONST,
'8' => self::SCALAR_CONST,
'9' => self::SCALAR_CONST,
'"' => self::SCALAR_STRING,
'{' => self::OBJECT_BEGIN,
'}' => self::OBJECT_END,
'[' => self::ARRAY_BEGIN,
']' => self::ARRAY_END,
',' => self::COMMA,
':' => self::COLON,
];
/**
* The token boundaries.
*
* @var array<string, bool>
*/
public const BOUNDARIES = [
'{' => true,
'}' => true,
'[' => true,
']' => true,
',' => true,
':' => true,
' ' => true,
"\n" => true,
"\r" => true,
"\t" => true,
"\xEF" => true,
"\xBB" => true,
"\xBF" => true,
];
/**
* The structural boundaries.
*
* @var array<string, bool>
*/
public const DELIMITERS = [
'{' => true,
'}' => true,
'[' => true,
']' => true,
',' => true,
':' => true,
];
/**
* The tokens class map.
*
* @var array<int, class-string<Token>>
*/
public const MAP = [
self::COMMA => Comma::class,
self::OBJECT_BEGIN => CompoundBegin::class,
self::ARRAY_BEGIN => CompoundBegin::class,
self::OBJECT_END => CompoundEnd::class,
self::ARRAY_END => CompoundEnd::class,
self::COLON => Colon::class,
self::SCALAR_CONST => Constant::class,
self::SCALAR_STRING => ScalarString::class,
];
}

View File

@ -0,0 +1,78 @@
<?php
namespace Cerbero\JsonParser\ValueObjects;
use Cerbero\JsonParser\Decoders\JsonDecoder;
use Cerbero\JsonParser\Decoders\DecodedValue;
use Cerbero\JsonParser\Decoders\Decoder;
use Cerbero\JsonParser\Decoders\SimdjsonDecoder;
use Cerbero\JsonParser\Exceptions\DecodingException;
use Cerbero\JsonParser\Exceptions\SyntaxException;
use Cerbero\JsonParser\Pointers\Pointer;
use Cerbero\JsonParser\Pointers\Pointers;
use Closure;
/**
* The configuration.
*
*/
final class Config
{
/**
* The JSON decoder.
*
* @var Decoder
*/
public Decoder $decoder;
/**
* The JSON pointers.
*
* @var Pointers
*/
public Pointers $pointers;
/**
* The number of bytes to read in each chunk.
*
* @var int<1, max>
*/
public int $bytes = 1024 * 8;
/**
* The callback to run during a decoding error.
*
* @var Closure
*/
public Closure $onDecodingError;
/**
* The callback to run during a syntax error.
*
* @var Closure
*/
public Closure $onSyntaxError;
/**
* Instantiate the class
*
*/
public function __construct()
{
$this->decoder = extension_loaded('simdjson') ? new SimdjsonDecoder() : new JsonDecoder();
$this->pointers = new Pointers();
$this->onDecodingError = fn (DecodedValue $decoded) => throw new DecodingException($decoded);
$this->onSyntaxError = fn (SyntaxException $e) => throw $e;
}
/**
* Clone the configuration
*
* @return void
*/
public function __clone(): void
{
$this->pointers = new Pointers();
$this->pointers->add(new Pointer('', true));
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace Cerbero\JsonParser\ValueObjects;
use function is_null;
/**
* The parsing progress.
*
*/
final class Progress
{
/**
* The current progress.
*
* @var int
*/
private int $current = 0;
/**
* The total possible progress.
*
* @var int|null
*/
private ?int $total = null;
/**
* Set the current progress
*
* @param int $current
* @return self
*/
public function setCurrent(int $current): self
{
$this->current = $current;
return $this;
}
/**
* Retrieve the current progress
*
* @return int
*/
public function current(): int
{
return $this->current;
}
/**
* Set the total possible progress
*
* @param int|null $total
* @return self
*/
public function setTotal(?int $total): self
{
$this->total ??= $total;
return $this;
}
/**
* Retrieve the total possible progress
*
* @return int|null
*/
public function total(): ?int
{
return $this->total;
}
/**
* Retrieve the formatted percentage of the progress
*
* @return string|null
*/
public function format(): ?string
{
return is_null($percentage = $this->percentage()) ? null : number_format($percentage, 1) . '%';
}
/**
* Retrieve the percentage of the progress
*
* @return float|null
*/
public function percentage(): ?float
{
return is_null($fraction = $this->fraction()) ? null : $fraction * 100;
}
/**
* Retrieve the fraction of the progress
*
* @return float|null
*/
public function fraction(): ?float
{
return $this->total ? $this->current / $this->total : null;
}
}

137
src/ValueObjects/State.php Normal file
View File

@ -0,0 +1,137 @@
<?php
namespace Cerbero\JsonParser\ValueObjects;
use Cerbero\JsonParser\Pointers\Pointers;
use Cerbero\JsonParser\Tokens\CompoundBegin;
use Cerbero\JsonParser\Tokens\Parser;
use Cerbero\JsonParser\Tokens\Token;
use Cerbero\JsonParser\Tokens\Tokens;
use Closure;
/**
* The JSON parsing state.
*
*/
final class State
{
/**
* The JSON tree.
*
* @var Tree
*/
public readonly Tree $tree;
/**
* The JSON buffer.
*
* @var Parser|string
*/
private Parser|string $buffer = '';
/**
* Whether an object key is expected.
*
* @var bool
*/
public bool $expectsKey = false;
/**
* The expected token.
*
* @var int
*/
public int $expectedToken = Tokens::COMPOUND_BEGIN;
/**
* Instantiate the class.
*
* @param Pointers $pointers
* @param Closure $lazyLoad
*/
public function __construct(private readonly Pointers $pointers, private readonly Closure $lazyLoad)
{
$this->tree = new Tree($pointers);
}
/**
* Retrieve the JSON tree
*
* @return Tree
*/
public function tree(): Tree
{
return $this->tree;
}
/**
* Determine whether the parser can stop parsing
*
* @return bool
*/
public function canStopParsing(): bool
{
return $this->pointers->wereFoundInTree($this->tree);
}
/**
* Call the current pointer callback
*
* @param mixed $value
* @param mixed $key
* @return mixed
*/
public function callPointer(mixed $value, mixed &$key): mixed
{
return $this->pointers->matching()->call($value, $key);
}
/**
* Mutate state depending on the given token
*
* @param Token $token
* @return void
*/
public function mutateByToken(Token $token): void
{
$this->tree->traverseToken($token, $this->expectsKey);
if ($this->tree->isMatched() && ((!$this->expectsKey && $token->isValue()) || $this->tree->isDeep())) {
$pointer = $this->pointers->markAsFound();
if ($token instanceof CompoundBegin && $pointer->isLazy) {
$this->buffer = ($this->lazyLoad)();
$token->shouldLazyLoad = true;
} else {
/** @phpstan-ignore-next-line */
$this->buffer .= $token;
}
}
$token->mutateState($this);
}
/**
* Determine whether the buffer contains tokens
*
* @return bool
*/
public function hasBuffer(): bool
{
return $this->buffer != '';
}
/**
* Retrieve the value from the buffer and reset it
*
* @return Parser|string
*/
public function value(): Parser|string
{
$buffer = $this->buffer;
$this->buffer = '';
return $buffer;
}
}

213
src/ValueObjects/Tree.php Normal file
View File

@ -0,0 +1,213 @@
<?php
namespace Cerbero\JsonParser\ValueObjects;
use Cerbero\JsonParser\Pointers\Pointers;
use Cerbero\JsonParser\Tokens\Token;
use function count;
/**
* The JSON tree.
*
*/
final class Tree
{
/**
* The original JSON tree.
*
* @var array<int, string|int>
*/
private array $original = [];
/**
* The wildcarded JSON tree.
*
* @var array<int, string|int>
*/
private array $wildcarded = [];
/**
* Whether a depth is within an object.
*
* @var array<int, bool>
*/
private array $inObjectByDepth = [];
/**
* The JSON tree depth.
*
* @var int
*/
private int $depth = -1;
/**
* Instantiate the class.
*
* @param Pointers $pointers
*/
public function __construct(private readonly Pointers $pointers)
{
}
/**
* Retrieve the original JSON tree
*
* @return array<int, string|int>
*/
public function original(): array
{
return $this->original;
}
/**
* Retrieve the wildcarded JSON tree
*
* @return array<int, string|int>
*/
public function wildcarded(): array
{
return $this->wildcarded;
}
/**
* Determine whether the current depth is within an object
*
* @return bool
*/
public function inObject(): bool
{
return $this->inObjectByDepth[$this->depth] ?? false;
}
/**
* Retrieve the JSON tree depth
*
* @return int
*/
public function depth(): int
{
return $this->depth;
}
/**
* Increase the tree depth by entering an object or an array
*
* @param bool $inObject
* @return void
*/
public function deepen(bool $inObject): void
{
$this->depth++;
$this->inObjectByDepth[$this->depth] = $inObject;
}
/**
* Decrease the tree depth
*
* @return void
*/
public function emerge(): void
{
$this->depth--;
}
/**
* Determine whether the tree is deep
*
* @return bool
*/
public function isDeep(): bool
{
$pointer = $this->pointers->matching();
return $pointer == '' ? $this->depth > 0 : $this->depth >= $pointer->depth;
}
/**
* Traverse the given token
*
* @param Token $token
* @param bool $expectsKey
* @return void
*/
public function traverseToken(Token $token, bool $expectsKey): void
{
$pointer = $this->pointers->matching();
if ($pointer != '' && $this->depth >= $pointer->depth) {
return;
} elseif ($expectsKey) {
$this->traverseKey($token);
} elseif ($token->isValue() && !$this->inObject()) {
$this->traverseArray();
}
}
/**
* Determine whether the tree is matched by the JSON pointer
*
* @return bool
*/
public function isMatched(): bool
{
return $this->depth >= 0 && $this->pointers->matching()->matchesTree($this);
}
/**
* Traverse the given object key
*
* @param string $key
* @return void
*/
public function traverseKey(string $key): void
{
$trimmedKey = substr($key, 1, -1);
$this->original[$this->depth] = $trimmedKey;
$this->wildcarded[$this->depth] = $trimmedKey;
if (count($this->original) > $offset = $this->depth + 1) {
array_splice($this->original, $offset);
array_splice($this->wildcarded, $offset);
array_splice($this->inObjectByDepth, $offset);
}
$this->pointers->matchTree($this);
}
/**
* Traverse an array
*
* @return void
*/
public function traverseArray(): void
{
$index = $this->original[$this->depth] ?? null;
$this->original[$this->depth] = $index = is_int($index) ? $index + 1 : 0;
if (count($this->original) > $offset = $this->depth + 1) {
array_splice($this->original, $offset);
array_splice($this->inObjectByDepth, $offset);
}
$referenceTokens = $this->pointers->matchTree($this)->referenceTokens;
$this->wildcarded[$this->depth] = ($referenceTokens[$this->depth] ?? null) == '-' ? '-' : $index;
if (count($this->wildcarded) > $offset) {
array_splice($this->wildcarded, $offset);
}
}
/**
* Retrieve the current key
*
* @return string|int
*/
public function currentKey(): string|int
{
$key = $this->original[$this->depth];
return is_string($key) ? "\"$key\"" : $key;
}
}

554
tests/Dataset.php Normal file
View File

@ -0,0 +1,554 @@
<?php
namespace Cerbero\JsonParser;
use Cerbero\JsonParser\Decoders\DecodedValue;
use Cerbero\JsonParser\Sources;
use Cerbero\JsonParser\Tokens\Parser;
use DirectoryIterator;
use Generator;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Request as Psr7Request;
use GuzzleHttp\Psr7\Response as Psr7Response;
use GuzzleHttp\Psr7\Stream;
use Illuminate\Http\Client\Request;
use Illuminate\Http\Client\Response;
use Mockery;
use Psr\Http\Message\ResponseInterface;
/**
* The dataset provider.
*
*/
final class Dataset
{
/**
* Retrieve the dataset to test parsing
*
* @return Generator
*/
public static function forParsing(): Generator
{
foreach (self::fixtures() as $fixture) {
$name = $fixture->getBasename('.json');
yield [
file_get_contents($fixture->getRealPath()),
require fixture("parsing/{$name}.php"),
];
}
}
/**
* Retrieve the fixtures
*
* @return Generator<int, DirectoryIterator>
*/
protected static function fixtures(): Generator
{
foreach (new DirectoryIterator(fixture('json')) as $file) {
if (!$file->isDot()) {
yield $file;
}
}
}
/**
* Retrieve the dataset to test invalid pointers
*
* @return Generator
*/
public static function forInvalidPointers(): Generator
{
yield from ['abc', '/foo~2', '/~', ' '];
}
/**
* Retrieve the dataset to test single pointers
*
* @return Generator
*/
public static function forSinglePointers(): Generator
{
yield from self::forSinglePointersWithFixture('pointers/single_pointer.php');
}
/**
* Retrieve the dataset to test single pointers with the given fixture
*
* @param string $path
* @return Generator
*/
private static function forSinglePointersWithFixture(string $path): Generator
{
$singlePointers = require fixture($path);
foreach ($singlePointers as $fixture => $pointers) {
$json = file_get_contents(fixture("json/{$fixture}.json"));
foreach ($pointers as $pointer => $value) {
yield [$json, $pointer, $value];
}
}
}
/**
* Retrieve the dataset to test single pointers eager loading
*
* @return Generator
*/
public static function forSinglePointersToArray(): Generator
{
yield from self::forSinglePointersWithFixture('pointers/single_pointer_to_array.php');
}
/**
* Retrieve the dataset to test the key update
*
* @return Generator
*/
public static function forKeyUpdate(): Generator
{
$json = fixture('json/complex_object.json');
$pointers = [
'/type' => function ($value, &$key) {
$key = 'foo';
},
];
yield [$json, $pointers, ['foo' => 'donut']];
}
/**
* Retrieve the dataset to test multiple pointers
*
* @return Generator
*/
public static function forMultiplePointers(): Generator
{
yield from self::forMultiplePointersWithFixture('pointers/multiple_pointers.php');
}
/**
* Retrieve the dataset to test multiple pointers with the given fixture
*
* @param string $path
* @return Generator
*/
private static function forMultiplePointersWithFixture(string $path): Generator
{
$multiplePointers = require fixture($path);
foreach ($multiplePointers as $fixture => $valueByPointers) {
$json = file_get_contents(fixture("json/{$fixture}.json"));
foreach ($valueByPointers as $pointers => $value) {
yield [$json, explode(',', $pointers), $value];
}
}
}
/**
* Retrieve the dataset to test multiple pointers eager loading
*
* @return Generator
*/
public static function forMultiplePointersToArray(): Generator
{
yield from self::forMultiplePointersWithFixture('pointers/multiple_pointers_to_array.php');
}
/**
* Retrieve the dataset to test intersecting pointers with wildcards
*
* @return Generator
*/
public static function forIntersectingPointersWithWildcards(): Generator
{
$json = fixture('json/complex_object.json');
$pointers = [
'/topping/6/type' => fn (string $value) => "$value @ /topping/6/type",
'/topping/-/type' => fn (string $value) => "$value @ /topping/-/type",
'/topping/0/type' => fn (string $value) => "$value @ /topping/0/type",
'/topping/2/type' => fn (string $value) => "$value @ /topping/2/type",
];
$parsed = [
'type' => [
'None @ /topping/0/type',
'Glazed @ /topping/-/type',
'Sugar @ /topping/2/type',
'Powdered Sugar @ /topping/-/type',
'Chocolate with Sprinkles @ /topping/-/type',
'Chocolate @ /topping/-/type',
'Maple @ /topping/6/type',
]
];
yield [$json, $pointers, $parsed];
}
/**
* Retrieve the dataset to test intersecting pointers
*
* @return Generator
*/
public static function forIntersectingPointers(): Generator
{
$json = fixture('json/complex_object.json');
$message = 'The pointers [%s] and [%s] are intersecting';
$pointersByIntersection = [
'/topping,/topping/0' => [
'/topping',
'/topping/0',
],
'/topping/0,/topping' => [
'/topping/0',
'/topping',
],
'/topping,/topping/-' => [
'/topping',
'/topping/-',
],
'/topping/-,/topping' => [
'/topping/-',
'/topping',
],
'/topping/0/type,/topping' => [
'/topping/0/type',
'/topping/-/type',
'/topping',
],
'/topping,/topping/-/type' => [
'/topping',
'/topping/-/type',
'/topping/0/type',
],
'/topping/-/type,/topping/-/type/baz' => [
'/topping/-/type',
'/topping/-/types',
'/topping/-/type/baz',
],
'/topping/-/type/baz,/topping/-/type' => [
'/topping/-/type/baz',
'/topping/-/type',
'/topping/-/types',
],
];
foreach ($pointersByIntersection as $intersection => $pointers) {
yield [$json, $pointers, vsprintf($message, explode(',', $intersection))];
}
}
/**
* Retrieve the dataset to test single lazy pointers
*
* @return Generator
*/
public static function forSingleLazyPointers(): Generator
{
$json = fixture('json/complex_object.json');
$sequenceByPointer = [
'' => [
fn ($value, $key) => $key->toBe('id')->and($value)->toBe('0001'),
fn ($value, $key) => $key->toBe('type')->and($value)->toBe('donut'),
fn ($value, $key) => $key->toBe('name')->and($value)->toBe('Cake'),
fn ($value, $key) => $key->toBe('ppu')->and($value)->toBe(0.55),
fn ($value, $key) => $key->toBe('batters')->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe('topping')->and($value)->toBeInstanceOf(Parser::class),
],
'/batters/batter/-' => [
fn ($value, $key) => $key->toBe(0)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(1)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(2)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(3)->and($value)->toBeInstanceOf(Parser::class),
],
'/topping/-' => [
fn ($value, $key) => $key->toBe(0)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(1)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(2)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(3)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(4)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(5)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(6)->and($value)->toBeInstanceOf(Parser::class),
],
];
foreach ($sequenceByPointer as $pointer => $sequence) {
yield [$json, $pointer, $sequence];
}
}
/**
* Retrieve the dataset to test multiple lazy pointers
*
* @return Generator
*/
public static function forMultipleLazyPointers(): Generator
{
$json = fixture('json/complex_object.json');
$sequenceByPointer = [
'/topping,/batters' => [
fn ($value, $key) => $key->toBe('batters')->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe('topping')->and($value)->toBeInstanceOf(Parser::class),
],
'/topping/-,/batters/batter' => [
fn ($value, $key) => $key->toBe('batter')->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(0)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(1)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(2)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(3)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(4)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(5)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(6)->and($value)->toBeInstanceOf(Parser::class),
],
];
foreach ($sequenceByPointer as $pointers => $sequence) {
yield [$json, explode(',', $pointers), $sequence];
}
}
/**
* Retrieve the dataset to test recursive lazy loading
*
* @return Generator
*/
public static function forRecursiveLazyLoading(): Generator
{
$json = fixture('json/complex_object.json');
$expectedByKeys = [
'batters,batter' => [
['id' => '1001', 'type' => 'Regular'],
['id' => '1002', 'type' => 'Chocolate'],
['id' => '1003', 'type' => 'Blueberry'],
['id' => '1004', 'type' => 'Devil\'s Food'],
],
'topping' => [
['id' => '5001', 'type' => 'None'],
['id' => '5002', 'type' => 'Glazed'],
['id' => '5005', 'type' => 'Sugar'],
['id' => '5007', 'type' => 'Powdered Sugar'],
['id' => '5006', 'type' => 'Chocolate with Sprinkles'],
['id' => '5003', 'type' => 'Chocolate'],
['id' => '5004', 'type' => 'Maple'],
],
];
foreach ($expectedByKeys as $keys => $expected) {
$keys = explode(',', $keys);
yield [$json, '/' . $keys[0], $keys, $expected];
}
}
/**
* Retrieve the dataset to test mixed pointers
*
* @return Generator
*/
public static function forMixedPointers(): Generator
{
$json = fixture('json/complex_object.json');
$pointersList = [
[
'/name' => fn (string $name) => "name_{$name}",
],
[
'/id' => fn (string $id) => "id_{$id}",
'/type' => fn (string $type) => "type_{$type}",
],
];
$lazyPointers = [
[
'/batters/batter' => fn (Parser $batter) => $batter::class,
],
[
'/batters' => fn (Parser $batters) => $batters::class,
'/topping' => fn (Parser $topping) => $topping::class,
],
];
$expected = [
[
'name' => 'name_Cake',
'batter' => Parser::class,
],
[
'id' => 'id_0001',
'type' => 'type_donut',
'batters' => Parser::class,
'topping' => Parser::class,
],
];
foreach ($pointersList as $index => $pointers) {
yield [$json, $pointers, $lazyPointers[$index], $expected[$index]];
}
}
/**
* Retrieve the dataset to test a global lazy pointer
*
* @return Generator
*/
public static function forGlobalLazyPointer(): Generator
{
$sequenceByFixture = [
'complex_object' => [
fn ($value, $key) => $key->toBe('id')->and($value)->toBe('0001'),
fn ($value, $key) => $key->toBe('type')->and($value)->toBe('donut'),
fn ($value, $key) => $key->toBe('name')->and($value)->toBe('Cake'),
fn ($value, $key) => $key->toBe('ppu')->and($value)->toBe(0.55),
fn ($value, $key) => $key->toBe('batters')->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe('topping')->and($value)->toBeInstanceOf(Parser::class),
],
'complex_array' => [
fn ($value, $key) => $key->toBe(0)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(1)->and($value)->toBeInstanceOf(Parser::class),
fn ($value, $key) => $key->toBe(2)->and($value)->toBeInstanceOf(Parser::class),
],
];
foreach ($sequenceByFixture as $fixture => $sequence) {
yield [fixture("json/{$fixture}.json"), $sequence];
}
}
/**
* Retrieve the dataset to test syntax errors
*
* @return Generator
*/
public static function forSyntaxErrors(): Generator
{
yield from require fixture('errors/syntax.php');
}
/**
* Retrieve the dataset to test decoding errors patching
*
* @return Generator
*/
public static function forDecodingErrorsPatching(): Generator
{
$patches = [null, 'baz', 123];
$json = '[1a, ""b, "foo", 3.1c4, falsed, null, [1, 2e], {"bar": 1, "baz"f: 2}]';
$patchJson = fn (mixed $patch) => [$patch, $patch, 'foo', $patch, $patch, null, $patch, $patch];
foreach ($patches as $patch) {
yield [$json, $patch, $patchJson($patch)];
}
$patch = fn (DecodedValue $decoded) => strrev($decoded->json);
$patched = ['a1', 'b""', 'foo', '4c1.3', 'deslaf', null, ']e2,1[', '}2:f"zab",1:"rab"{'];
yield [$json, $patch, $patched];
}
/**
* Retrieve the dataset to test sources requiring Guzzle
*
* @return Generator
*/
public static function forSourcesRequiringGuzzle(): Generator
{
$sources = [Sources\Endpoint::class, Sources\Psr7Request::class];
foreach ($sources as $source) {
yield Mockery::mock($source)
->makePartial()
->shouldAllowMockingProtectedMethods()
->shouldReceive('guzzleIsInstalled')
->andReturn(false)
->getMock();
}
}
/**
* Retrieve the dataset to test decoders
*
* @return Generator
*/
public static function forDecoders(): Generator
{
$json = '{"foo":"bar"}';
$values = [
true => ['foo' => 'bar'],
false => (object) ['foo' => 'bar'],
];
foreach ([true, false] as $decodesToArray) {
yield [$decodesToArray, $json, $values[$decodesToArray]];
}
}
/**
* Retrieve the dataset to test sources
*
* @return Generator
*/
public static function forSources(): Generator
{
$parsed = require fixture('parsing/simple_array.php');
$path = fixture('json/simple_array.json');
$json = file_get_contents($path);
$size = strlen($json);
$request = new Psr7Request('GET', 'foo');
$response = Mockery::mock(ResponseInterface::class)
->shouldReceive('getBody')
->andReturnUsing(fn () => new Stream(fopen($path, 'rb')))
->getMock();
$client = Mockery::mock(Client::class)
->shouldReceive('get', 'sendRequest')
->andReturn($response)
->getMock();
$endpoint = Mockery::mock(Sources\Endpoint::class, ['https://example.com'])
->makePartial()
->shouldAllowMockingProtectedMethods()
->shouldReceive('guzzle')
->andReturn($client)
->getMock();
$laravelClientRequest = Mockery::mock(Sources\LaravelClientRequest::class, [new Request($request)])
->makePartial()
->shouldAllowMockingProtectedMethods()
->shouldReceive('guzzle')
->andReturn($client)
->getMock();
$psr7Response = Mockery::mock(Psr7Response::class)
->shouldReceive('getBody')
->andReturn(new Stream(fopen($path, 'rb')))
->getMock();
$psr7Request = Mockery::mock(Sources\Psr7Request::class, [$request])
->makePartial()
->shouldAllowMockingProtectedMethods()
->shouldReceive('guzzle')
->andReturn($client)
->getMock();
$sources = [
new Sources\AnySource(new Sources\Json($json)),
new Sources\CustomSource(new Sources\Json($json)),
$endpoint,
new Sources\Filename($path),
new Sources\IterableSource(str_split($json)),
new Sources\Json($json),
new Sources\JsonResource(fopen($path, 'rb')),
$laravelClientRequest,
new Sources\LaravelClientResponse(new Response($psr7Response)),
new Sources\Psr7Message($response),
$psr7Request,
new Sources\Psr7Stream(new Stream(fopen($path, 'rb'))),
];
foreach ($sources as $source) {
yield [$source, $size, $parsed];
}
}
}

View File

@ -0,0 +1,44 @@
<?php
use Cerbero\JsonParser\Dataset;
use Cerbero\JsonParser\Decoders\DecodedValue;
use Cerbero\JsonParser\Exceptions\DecodingException;
use Cerbero\JsonParser\Exceptions\SyntaxException;
use Cerbero\JsonParser\JsonParser;
it('throws a syntax exception on unexpected tokens', function (string $json, string $unexpected, int $position) {
expect(fn () => JsonParser::parse($json)->traverse())
->toThrow(SyntaxException::class, "Syntax error: unexpected '$unexpected' at position {$position}");
})->with(Dataset::forSyntaxErrors());
it('lets the user handle syntax errors', function () {
JsonParser::parse('{a}')
->onSyntaxError(function (SyntaxException $e) {
expect($e)
->getMessage()->toBe("Syntax error: unexpected 'a' at position 1")
->value->toBe('a')
->position->toBe(1);
})
->traverse();
});
it('throws a decoding exception if unable to decode a JSON fragment', function () {
JsonParser::parse(fixture('errors/decoding.json'))->traverse();
})->throws(DecodingException::class, 'Decoding error: Problem while parsing a number');
it('lets the user handle decoding errors', function () {
$decodingErrors = [];
JsonParser::parse(fixture('errors/decoding.json'))
->onDecodingError(function (DecodedValue $decoded) use (&$decodingErrors) {
$decodingErrors[] = $decoded->json;
})
->traverse();
expect($decodingErrors)->toBe(['1a', '""b', '3.c14', '[f]']);
});
it('lets the user patch decoding errors', function (string $json, mixed $patch, array $patched) {
expect(JsonParser::parse($json)->patchDecodingError($patch))->toParseTo($patched);
})->with(Dataset::forDecodingErrorsPatching());

View File

@ -0,0 +1,44 @@
<?php
use Cerbero\JsonParser\Dataset;
use Cerbero\JsonParser\Decoders\SimdjsonDecoder;
use Cerbero\JsonParser\JsonParser;
use function Cerbero\JsonParser\parseJson;
it('parses JSON when instantiated', function (string $json, array $parsed) {
expect(new JsonParser($json))->toParseTo($parsed);
})->with(Dataset::forParsing());
it('parses JSON when instantiated statically', function (string $json, array $parsed) {
expect(JsonParser::parse($json))->toParseTo($parsed);
})->with(Dataset::forParsing());
it('parses JSON when calling the helper', function (string $json, array $parsed) {
expect(parseJson($json))->toParseTo($parsed);
})->with(Dataset::forParsing());
it('parses with custom decoders', function (string $json, array $parsed) {
expect(JsonParser::parse($json)->decoder(new SimdjsonDecoder()))->toParseTo($parsed);
})->with(Dataset::forParsing());
it('parses a custom number of bytes', function (string $json, array $parsed) {
expect(JsonParser::parse($json)->bytes(1024))->toParseTo($parsed);
})->with(Dataset::forParsing());
it('eager loads JSON into an array', function (string $json, array $parsed) {
expect(JsonParser::parse($json)->toArray())->toBe($parsed);
})->with(Dataset::forParsing());
it('shows the progress while parsing', function () {
$parser = new JsonParser(fixture('json/simple_array.json'));
expect($parser->progress()->percentage())->toBe($percentage = 0.0);
foreach ($parser as $value) {
expect($percentage)->toBeLessThan($percentage = $parser->progress()->percentage());
}
expect($parser->progress()->percentage())->toBe(100.0);
});

View File

@ -0,0 +1,69 @@
<?php
use Cerbero\JsonParser\Dataset;
use Cerbero\JsonParser\Exceptions\IntersectingPointersException;
use Cerbero\JsonParser\Exceptions\InvalidPointerException;
use Cerbero\JsonParser\JsonParser;
it('throws an exception when providing an invalid JSON pointer', function (string $pointer) {
expect(fn () => JsonParser::parse('{}')->pointer($pointer)->traverse())
->toThrow(InvalidPointerException::class, "The string [$pointer] is not a valid JSON pointer");
})->with(Dataset::forInvalidPointers());
it('loads JSON from a single JSON pointer', function (string $json, string $pointer, array $parsed) {
expect(JsonParser::parse($json)->pointer($pointer))->toPointTo($parsed);
})->with(Dataset::forSinglePointers());
it('eager loads pointers into an array', function (string $json, string $pointer, array $expected) {
expect(JsonParser::parse($json)->pointer($pointer)->toArray())->toBe($expected);
})->with(Dataset::forSinglePointersToArray());
it('eager loads lazy pointers into an array', function (string $json, string $pointer, array $expected) {
expect(JsonParser::parse($json)->lazyPointer($pointer)->toArray())->toBe($expected);
})->with(Dataset::forSinglePointersToArray());
it('can modify key and value of a pointer', function (string $json, array $pointers, array $expected) {
expect(JsonParser::parse($json)->pointers($pointers)->toArray())->toBe($expected);
})->with(Dataset::forKeyUpdate());
it('loads JSON from multiple JSON pointers', function (string $json, array $pointers, array $parsed) {
expect(JsonParser::parse($json)->pointers($pointers))->toPointTo($parsed);
})->with(Dataset::forMultiplePointers());
it('eager loads multiple pointers into an array', function (string $json, array $pointers, array $expected) {
expect(JsonParser::parse($json)->pointers($pointers)->toArray())->toBe($expected);
})->with(Dataset::forMultiplePointersToArray());
it('eager loads multiple lazy pointers into an array', function (string $json, array $pointers, array $expected) {
expect(JsonParser::parse($json)->lazyPointers($pointers)->toArray())->toBe($expected);
})->with(Dataset::forMultiplePointersToArray());
it('can intersect pointers with wildcards', function (string $json, array $pointers, array $parsed) {
expect(JsonParser::parse($json)->pointers($pointers))->toPointTo($parsed);
})->with(Dataset::forIntersectingPointersWithWildcards());
it('throws an exception when two pointers intersect', function (string $json, array $pointers, string $message) {
expect(fn () => JsonParser::parse($json)->pointers($pointers))
->toThrow(IntersectingPointersException::class, $message);
})->with(Dataset::forIntersectingPointers());
it('lazy loads JSON from a single lazy JSON pointer', function (string $json, string $pointer, array $sequence) {
expect(JsonParser::parse($json)->lazyPointer($pointer))->sequence(...$sequence);
})->with(Dataset::forSingleLazyPointers());
it('lazy loads JSON from multiple lazy JSON pointers', function (string $json, array $pointers, array $sequence) {
expect(JsonParser::parse($json)->lazyPointers($pointers))->sequence(...$sequence);
})->with(Dataset::forMultipleLazyPointers());
it('lazy loads JSON recursively', function (string $json, string $pointer, array $keys, array $expected) {
expect(JsonParser::parse($json)->lazyPointer($pointer))->toLazyLoadRecursively($keys, $expected);
})->with(Dataset::forRecursiveLazyLoading());
it('mixes pointers and lazy pointers', function (string $json, array $pointers, array $lazyPointers, array $expected) {
expect(JsonParser::parse($json)->pointers($pointers)->lazyPointers($lazyPointers))->toParseTo($expected);
})->with(Dataset::forMixedPointers());
it('lazy loads an entire JSON', function (string $json, array $sequence) {
expect(JsonParser::parse($json)->lazy())->sequence(...$sequence);
})->with(Dataset::forGlobalLazyPointer());

View File

@ -0,0 +1,27 @@
<?php
use Cerbero\JsonParser\Dataset;
use Cerbero\JsonParser\Exceptions\GuzzleRequiredException;
use Cerbero\JsonParser\Exceptions\UnsupportedSourceException;
use Cerbero\JsonParser\JsonParser;
use Cerbero\JsonParser\Sources\Source;
it('throws an exception when a JSON source is not supported', function () {
expect(fn () => JsonParser::parse(123)->traverse())
->toThrow(UnsupportedSourceException::class, 'Unable to load JSON from the provided source');
});
it('throws an exception when Guzzle is required but not installed', function (Source $source) {
expect(fn () => JsonParser::parse($source)->traverse())
->toThrow(GuzzleRequiredException::class, 'Guzzle is required to load JSON from endpoints');
})->with(Dataset::forSourcesRequiringGuzzle());
it('supports multiple sources', function (Source $source, int $size, array $parsed) {
expect($source)
->getIterator()->toBeInstanceOf(Traversable::class)
->matches()->toBeTrue()
->size()->toBe($size);
expect(new JsonParser($source))->toParseTo($parsed);
})->with(Dataset::forSources());

View File

@ -1,26 +0,0 @@
<?php
namespace Cerbero\JsonParser;
use Cerbero\JsonParser\Providers\JsonParserServiceProvider;
use Orchestra\Testbench\TestCase;
/**
* The package test suite.
*
*/
class JsonParserTest extends TestCase
{
/**
* Retrieve the package providers.
*
* @param \Illuminate\Foundation\Application $app
* @return array
*/
protected function getPackageProviders($app)
{
return [
JsonParserServiceProvider::class,
];
}
}

77
tests/Pest.php Normal file
View File

@ -0,0 +1,77 @@
<?php
use Cerbero\JsonParser\Tokens\Parser;
if (!function_exists('fixture')) {
/**
* Retrieve the absolute path of the given fixture
*
* @param string $fixture
* @return string
*/
function fixture(string $fixture): string
{
return __DIR__ . "/fixtures/{$fixture}";
}
}
/**
* Expect that keys and values are parsed correctly
*
* @param array $expected
* @return Expectation
*/
expect()->extend('toParseTo', function (array $expected) {
$actual = [];
foreach ($this->value as $parsedKey => $parsedValue) {
expect($expected)->toHaveKey($parsedKey, $parsedValue);
$actual[$parsedKey] = $parsedValue;
}
return expect($actual)->toBe($expected);
});
/**
* Expect that values defined by JSON pointers are parsed correctly
*
* @param array $expected
* @return Expectation
*/
expect()->extend('toPointTo', function (array $expected) {
$actual = $itemsCount = [];
foreach ($this->value as $parsedKey => $parsedValue) {
$itemsCount[$parsedKey] = empty($itemsCount[$parsedKey]) ? 1 : $itemsCount[$parsedKey] + 1;
// associate $parsedKey to $parsedValue if $parsedKey occurs once
// associate $parsedKey to an array of $parsedValue if $parsedKey occurs multiple times
$actual[$parsedKey] = match ($itemsCount[$parsedKey]) {
1 => $parsedValue,
2 => [$actual[$parsedKey], $parsedValue],
default => [...$actual[$parsedKey], $parsedValue],
};
}
return expect($actual)->toBe($expected);
});
/**
* Expect that values defined by lazy JSON pointers are parsed correctly
*
* @param array $keys
* @param array $expected
* @return Expectation
*/
expect()->extend('toLazyLoadRecursively', function (array $keys, array $expected) {
foreach ($this->value as $key => $value) {
expect($value)->toBeInstanceOf(Parser::class);
if (is_null($expectedKey = array_shift($keys))) {
expect($key)->toBeInt()->and($value)->toParseTo($expected[$key]);
} else {
expect($key)->toBe($expectedKey)->and($value)->toLazyLoadRecursively($keys, $expected);
}
}
});

View File

@ -0,0 +1,33 @@
<?php
use Cerbero\JsonParser\Dataset;
use Cerbero\JsonParser\Decoders\DecodedValue;
use Cerbero\JsonParser\Decoders\JsonDecoder;
it('decodes values when a JSON is valid', function (bool $decodesToArray, string $json, mixed $value) {
expect($decoded = (new JsonDecoder($decodesToArray))->decode($json))
->toBeInstanceOf(DecodedValue::class)
->succeeded->toBeTrue()
->value->toEqual($value)
->error->toBeNull()
->code->toBeNull()
->exception->toBeNull();
expect($decoded->json)->toBeNull();
})->with(Dataset::forDecoders());
it('reports issues when a JSON is not valid', function () {
$json = '[1a]';
$e = new JsonException('Syntax error', 4);
expect($decoded = (new JsonDecoder())->decode($json))
->toBeInstanceOf(DecodedValue::class)
->succeeded->toBeFalse()
->value->toBeNull()
->error->toBe($e->getMessage())
->code->toBe($e->getCode())
->exception->toEqual($e);
expect($decoded->json)->toBe($json);
});

View File

@ -0,0 +1,33 @@
<?php
use Cerbero\JsonParser\Dataset;
use Cerbero\JsonParser\Decoders\DecodedValue;
use Cerbero\JsonParser\Decoders\SimdjsonDecoder;
it('decodes values when a JSON is valid', function (bool $decodesToArray, string $json, mixed $value) {
expect($decoded = (new SimdjsonDecoder($decodesToArray))->decode($json))
->toBeInstanceOf(DecodedValue::class)
->succeeded->toBeTrue()
->value->toEqual($value)
->error->toBeNull()
->code->toBeNull()
->exception->toBeNull();
expect($decoded->json)->toBeNull();
})->with(Dataset::forDecoders());
it('reports issues when a JSON is not valid', function () {
$json = '[1a]';
$e = new SimdJsonException('Problem while parsing a number', 9);
expect($decoded = (new SimdjsonDecoder())->decode($json))
->toBeInstanceOf(DecodedValue::class)
->succeeded->toBeFalse()
->value->toBeNull()
->error->toBe($e->getMessage())
->code->toBe($e->getCode())
->exception->toEqual($e);
expect($decoded->json)->toBe($json);
});

View File

@ -0,0 +1,24 @@
<?php
use Cerbero\JsonParser\ValueObjects\Progress;
it('tracks the progress', function () {
$progress = new Progress();
expect($progress)
->current()->toBe(0)
->total()->toBeNull()
->format()->toBeNull()
->percentage()->toBeNull()
->fraction()->toBeNull();
$progress->setTotal(200)->setCurrent(33);
expect($progress)
->current()->toBe(33)
->total()->toBe(200)
->format()->toBe('16.5%')
->percentage()->toBe(16.5)
->fraction()->toBe(0.165);
});

1
tests/fixtures/errors/decoding.json vendored Normal file
View File

@ -0,0 +1 @@
[1a, ""b, 3.c14, [f]]

54
tests/fixtures/errors/syntax.php vendored Normal file
View File

@ -0,0 +1,54 @@
<?php
return [
[
'json' => 'a[1, "", 3.14, [], {}]',
'unexpected' => 'a',
'position' => 0,
],
[
'json' => '[b1, "", 3.14, [], {}]',
'unexpected' => 'b',
'position' => 1,
],
[
'json' => '[1,c "", 3.14, [], {}]',
'unexpected' => 'c',
'position' => 3,
],
[
'json' => '[1, d"", 3.14, [], {}]',
'unexpected' => 'd',
'position' => 4,
],
[
'json' => '[1, "", e3.14, [], {}]',
'unexpected' => 'e',
'position' => 8,
],
[
'json' => '[1, "", 3.14, []f, {}]',
'unexpected' => 'f',
'position' => 17,
],
[
'json' => '[1, "", 3.14, [], g{}]',
'unexpected' => 'g',
'position' => 18,
],
[
'json' => '[1, "", 3.14, [], {h}]',
'unexpected' => 'h',
'position' => 19,
],
[
'json' => '[1, "", 3.14, [], {}i]',
'unexpected' => 'i',
'position' => 20,
],
[
'json' => '[1, "", 3.14, [], {}]j',
'unexpected' => 'j',
'position' => 21,
],
];

70
tests/fixtures/json/complex_array.json vendored Normal file
View File

@ -0,0 +1,70 @@
[
{
"id": "0001",
"type": "donut",
"name": "Cake",
"ppu": 0.55,
"batters":
{
"batter":
[
{ "id": "1001", "type": "Regular" },
{ "id": "1002", "type": "Chocolate" },
{ "id": "1003", "type": "Blueberry" },
{ "id": "1004", "type": "Devil's Food" }
]
},
"topping":
[
{ "id": "5001", "type": "None" },
{ "id": "5002", "type": "Glazed" },
{ "id": "5005", "type": "Sugar" },
{ "id": "5007", "type": "Powdered Sugar" },
{ "id": "5006", "type": "Chocolate with Sprinkles" },
{ "id": "5003", "type": "Chocolate" },
{ "id": "5004", "type": "Maple" }
]
},
{
"id": "0002",
"type": "donut",
"name": "Raised",
"ppu": 0.55,
"batters":
{
"batter":
[
{ "id": "1001", "type": "Regular" }
]
},
"topping":
[
{ "id": "5001", "type": "None" },
{ "id": "5002", "type": "Glazed" },
{ "id": "5005", "type": "Sugar" },
{ "id": "5003", "type": "Chocolate" },
{ "id": "5004", "type": "Maple" }
]
},
{
"id": "0003",
"type": "donut",
"name": "Old Fashioned",
"ppu": 0.55,
"batters":
{
"batter":
[
{ "id": "1001", "type": "Regular" },
{ "id": "1002", "type": "Chocolate" }
]
},
"topping":
[
{ "id": "5001", "type": "None" },
{ "id": "5002", "type": "Glazed" },
{ "id": "5003", "type": "Chocolate" },
{ "id": "5004", "type": "Maple" }
]
}
]

26
tests/fixtures/json/complex_object.json vendored Normal file
View File

@ -0,0 +1,26 @@
{
"id": "0001",
"type": "donut",
"name": "Cake",
"ppu": 0.55,
"batters":
{
"batter":
[
{ "id": "1001", "type": "Regular" },
{ "id": "1002", "type": "Chocolate" },
{ "id": "1003", "type": "Blueberry" },
{ "id": "1004", "type": "Devil's Food" }
]
},
"topping":
[
{ "id": "5001", "type": "None" },
{ "id": "5002", "type": "Glazed" },
{ "id": "5005", "type": "Sugar" },
{ "id": "5007", "type": "Powdered Sugar" },
{ "id": "5006", "type": "Chocolate with Sprinkles" },
{ "id": "5003", "type": "Chocolate" },
{ "id": "5004", "type": "Maple" }
]
}

1
tests/fixtures/json/empty_array.json vendored Normal file
View File

@ -0,0 +1 @@
[]

1
tests/fixtures/json/empty_object.json vendored Normal file
View File

@ -0,0 +1 @@
{}

1
tests/fixtures/json/simple_array.json vendored Normal file
View File

@ -0,0 +1 @@
[1, "", "foo", "\"bar\"", "hej då", 3.14, false, null, [], {}]

22
tests/fixtures/json/simple_object.json vendored Normal file
View File

@ -0,0 +1,22 @@
{
"int": 1,
"empty_string": "",
"string": "foo",
"escaped_string": "\"bar\"",
"\"escaped_key\"": "baz",
"unicode": "hej då",
"float": 3.14,
"bool": false,
"null": null,
"empty_array": [],
"empty_object": {},
"": 0,
"a/b": 1,
"c%d": 2,
"e^f": 3,
"g|h": 4,
"i\\j": 5,
"k\"l": 6,
" ": 7,
"m~n": 8
}

132
tests/fixtures/parsing/complex_array.php vendored Normal file
View File

@ -0,0 +1,132 @@
<?php
return [
[
"id" => "0001",
"type" => "donut",
"name" => "Cake",
"ppu" => 0.55,
"batters" => [
"batter" => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
],
"topping" => [
[
"id" => "5001",
"type" => "None",
],
[
"id" => "5002",
"type" => "Glazed",
],
[
"id" => "5005",
"type" => "Sugar",
],
[
"id" => "5007",
"type" => "Powdered Sugar",
],
[
"id" => "5006",
"type" => "Chocolate with Sprinkles",
],
[
"id" => "5003",
"type" => "Chocolate",
],
[
"id" => "5004",
"type" => "Maple",
],
],
],
[
"id" => "0002",
"type" => "donut",
"name" => "Raised",
"ppu" => 0.55,
"batters" => [
"batter" => [
[
"id" => "1001",
"type" => "Regular",
],
],
],
"topping" => [
[
"id" => "5001",
"type" => "None",
],
[
"id" => "5002",
"type" => "Glazed",
],
[
"id" => "5005",
"type" => "Sugar",
],
[
"id" => "5003",
"type" => "Chocolate",
],
[
"id" => "5004",
"type" => "Maple",
],
],
],
[
"id" => "0003",
"type" => "donut",
"name" => "Old Fashioned",
"ppu" => 0.55,
"batters" => [
"batter" => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
],
],
"topping" => [
[
"id" => "5001",
"type" => "None",
],
[
"id" => "5002",
"type" => "Glazed",
],
[
"id" => "5003",
"type" => "Chocolate",
],
[
"id" => "5004",
"type" => "Maple",
],
],
],
];

View File

@ -0,0 +1,58 @@
<?php
return [
'id' => '0001',
'type' => 'donut',
'name' => 'Cake',
'ppu' => 0.55,
'batters' => [
'batter' => [
[
'id' => '1001',
'type' => 'Regular',
],
[
'id' => '1002',
'type' => 'Chocolate',
],
[
'id' => '1003',
'type' => 'Blueberry',
],
[
'id' => '1004',
'type' => 'Devil\'s Food',
],
],
],
'topping' => [
[
'id' => '5001',
'type' => 'None',
],
[
'id' => '5002',
'type' => 'Glazed',
],
[
'id' => '5005',
'type' => 'Sugar',
],
[
'id' => '5007',
'type' => 'Powdered Sugar',
],
[
'id' => '5006',
'type' => 'Chocolate with Sprinkles',
],
[
'id' => '5003',
'type' => 'Chocolate',
],
[
'id' => '5004',
'type' => 'Maple',
],
],
];

View File

@ -0,0 +1,3 @@
<?php
return [];

View File

@ -0,0 +1,3 @@
<?php
return [];

View File

@ -0,0 +1,3 @@
<?php
return [1, '', 'foo', '"bar"', 'hej då', 3.14, false, null, [], []];

View File

@ -0,0 +1,24 @@
<?php
return [
'int' => 1,
'empty_string' => '',
'string' => 'foo',
'escaped_string' => '"bar"',
'"escaped_key"' => 'baz',
"unicode" => "hej då",
'float' => 3.14,
'bool' => false,
'null' => null,
'empty_array' => [],
'empty_object' => [],
'' => 0,
'a/b' => 1,
'c%d' => 2,
'e^f' => 3,
'g|h' => 4,
'i\\j' => 5,
'k"l' => 6,
' ' => 7,
'm~n' => 8
];

View File

@ -0,0 +1,160 @@
<?php
return [
'complex_array' => [
'/-1,/-2' => [],
'/-/id,/-/batters/batter/-/type' => [
'id' => ['0001', '0002', '0003'],
'type' => ['Regular', 'Chocolate', 'Blueberry', "Devil's Food", 'Regular', 'Regular', 'Chocolate'],
],
'/-/name,/-/topping/-/type,/-/id' => [
'id' => ['0001', '0002', '0003'],
'name' => ['Cake', 'Raised', 'Old Fashioned'],
'type' => ['None', 'Glazed', 'Sugar', 'Powdered Sugar', 'Chocolate with Sprinkles', 'Chocolate', 'Maple', 'None', 'Glazed', 'Sugar', 'Chocolate', 'Maple', 'None', 'Glazed', 'Chocolate', 'Maple'],
],
'/-/batters/batter/-,/-/name' => [
'name' => ['Cake', 'Raised', 'Old Fashioned'],
0 => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1001",
"type" => "Regular",
],
],
1 => [
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1002",
"type" => "Chocolate",
],
],
2 => [
"id" => "1003",
"type" => "Blueberry",
],
3 => [
"id" => "1004",
"type" => "Devil's Food",
],
],
],
'complex_object' => [
'/-1,/-2' => [],
'/id,/batters/batter/-/type' => [
'id' => '0001',
'type' => ['Regular', 'Chocolate', 'Blueberry', "Devil's Food"],
],
'/name,/topping/-/type,/id' => [
'id' => '0001',
'name' => 'Cake',
'type' => ['None', 'Glazed', 'Sugar', 'Powdered Sugar', 'Chocolate with Sprinkles', 'Chocolate', 'Maple'],
],
'/batters/batter/-,/type' => [
'type' => 'donut',
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
'/batters/batter,/topping' => [
'batter' => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
'topping' => [
[
"id" => "5001",
"type" => "None",
],
[
"id" => "5002",
"type" => "Glazed",
],
[
"id" => "5005",
"type" => "Sugar",
],
[
"id" => "5007",
"type" => "Powdered Sugar",
],
[
"id" => "5006",
"type" => "Chocolate with Sprinkles",
],
[
"id" => "5003",
"type" => "Chocolate",
],
[
"id" => "5004",
"type" => "Maple",
],
],
],
],
'empty_array' => [
'/-1,/-2' => [],
'/foo,/bar' => [],
],
'empty_object' => [
'/-1,/-2' => [],
'/foo,/bar' => [],
],
'simple_array' => [
'/-1,/-2' => [],
'/0,/1' => [0 => 1, 1 => ''],
'/1,/0' => [0 => 1, 1 => ''],
'/0,/2' => [0 => 1, 2 => 'foo'],
'/2,/3' => [2 => 'foo', 3 => '"bar"'],
'/3,/4,/5' => [3 => '"bar"', 4 => 'hej då', 5 => 3.14],
'/4,/5,/3' => [3 => '"bar"', 4 => 'hej då', 5 => 3.14],
'/6,/7,/8,/9' => [6 => false, 7 => null, 8 => [], 9 => []],
'/9,/8,/7,/6' => [6 => false, 7 => null, 8 => [], 9 => []],
],
'simple_object' => [
'/-1,/-2' => [],
'/int,/empty_string' => ['int' => 1, 'empty_string' => ''],
'/empty_string,/int' => ['int' => 1, 'empty_string' => ''],
'/string,/escaped_string,/\"escaped_key\"' => ['string' => 'foo', 'escaped_string' => '"bar"', '"escaped_key"' => 'baz'],
'/unicode,/bool,/empty_array' => ['unicode' => "hej då", 'bool' => false, 'empty_array' => []],
'/,/a~1b,/c%d,/e^f,/g|h,/i\\\\j' => ['' => 0, 'a/b' => 1, 'c%d' => 2, 'e^f' => 3, 'g|h' => 4, 'i\\j' => 5],
'/k\"l,/ ,/m~0n' => ['k"l' => 6, ' ' => 7, 'm~n' => 8],
],
];

View File

@ -0,0 +1,94 @@
<?php
return [
'complex_array' => [
'/-1,/-2' => [],
'/-/id,/-/batters/batter/-/type' => [
'id' => '0003',
'type' => 'Chocolate',
],
'/-/name,/-/topping/-/type,/-/id' => [
'id' => '0003',
'name' => 'Old Fashioned',
'type' => 'Maple',
],
'/-/batters/batter/-,/-/name' => [
'name' => 'Old Fashioned',
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
],
'complex_object' => [
'/-1,/-2' => [],
'/id,/batters/batter/-/type' => [
'id' => '0001',
'type' => "Devil's Food",
],
'/name,/topping/-/type,/id' => [
'id' => '0001',
'name' => 'Cake',
'type' => 'Maple',
],
'/batters/batter/-,/type' => [
'type' => 'donut',
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
],
'empty_array' => [
'/-1,/-2' => [],
'/foo,/bar' => [],
],
'empty_object' => [
'/-1,/-2' => [],
'/foo,/bar' => [],
],
'simple_array' => [
'/-1,/-2' => [],
'/0,/1' => [0 => 1, 1 => ''],
'/1,/0' => [0 => 1, 1 => ''],
'/0,/2' => [0 => 1, 2 => 'foo'],
'/2,/3' => [2 => 'foo', 3 => '"bar"'],
'/3,/4,/5' => [3 => '"bar"', 4 => 'hej då', 5 => 3.14],
'/4,/5,/3' => [3 => '"bar"', 4 => 'hej då', 5 => 3.14],
'/6,/7,/8,/9' => [6 => false, 7 => null, 8 => [], 9 => []],
'/9,/8,/7,/6' => [6 => false, 7 => null, 8 => [], 9 => []],
],
'simple_object' => [
'/-1,/-2' => [],
'/int,/empty_string' => ['int' => 1, 'empty_string' => ''],
'/empty_string,/int' => ['int' => 1, 'empty_string' => ''],
'/string,/escaped_string,/\"escaped_key\"' => ['string' => 'foo', 'escaped_string' => '"bar"', '"escaped_key"' => 'baz'],
'/unicode,/bool,/empty_array' => ['unicode' => "hej då", 'bool' => false, 'empty_array' => []],
'/,/a~1b,/c%d,/e^f,/g|h,/i\\\\j' => ['' => 0, 'a/b' => 1, 'c%d' => 2, 'e^f' => 3, 'g|h' => 4, 'i\\j' => 5],
'/k\"l,/ ,/m~0n' => ['k"l' => 6, ' ' => 7, 'm~n' => 8],
],
];

View File

@ -0,0 +1,250 @@
<?php
return [
'complex_array' => [
'' => $complexArray = require __DIR__ . '/../parsing/complex_array.php',
'/-' => $complexArray,
'/-/id' => ['id' => ['0001', '0002', '0003']],
'/-/batters' => [
'batters' => [
[
'batter' => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
],
[
'batter' => [
[
"id" => "1001",
"type" => "Regular",
],
],
],
[
'batter' => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
],
],
],
],
'/-/batters/batter' => [
'batter' => [
[
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
[
[
"id" => "1001",
"type" => "Regular",
],
],
[
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
],
],
],
'/-/batters/batter/-' => [
0 => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1001",
"type" => "Regular",
],
],
1 => [
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1002",
"type" => "Chocolate",
],
],
2 => [
"id" => "1003",
"type" => "Blueberry",
],
3 => [
"id" => "1004",
"type" => "Devil's Food",
],
],
'/-/batters/batter/-/id' => ['id' => ["1001", "1002", "1003", "1004", "1001", "1001", "1002"]],
],
'complex_object' => [
'' => require __DIR__ . '/../parsing/complex_object.php',
'/-' => [],
'/id' => ['id' => '0001'],
'/batters' => [
'batters' => [
'batter' => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
],
],
'/batters/batter' => [
'batter' => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
],
'/batters/batter/-' => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
'/batters/batter/-/id' => ['id' => ["1001", "1002", "1003", "1004"]],
],
'empty_array' => [
'' => [],
'/-' => [],
'/-1' => [],
'/0' => [],
'/foo' => [],
],
'empty_object' => [
'' => [],
'/-' => [],
'/-1' => [],
'/0' => [],
'/foo' => [],
],
'simple_array' => [
'' => $simpleArray = require __DIR__ . '/../parsing/simple_array.php',
'/-' => $simpleArray,
'/-1' => [],
'/0' => [0 => 1],
'/1' => [1 => ''],
'/2' => [2 => 'foo'],
'/3' => [3 => '"bar"'],
'/4' => [4 => 'hej då'],
'/5' => [5 => 3.14],
'/6' => [6 => false],
'/7' => [7 => null],
'/8' => [8 => []],
'/9' => [9 => []],
'/10' => [],
'/foo' => [],
],
'simple_object' => [
'' => require __DIR__ . '/../parsing/simple_object.php',
'/-' => [],
'/-1' => [],
'/int' => ['int' => 1],
'/empty_string' => ['empty_string' => ''],
'/string' => ['string' => 'foo'],
'/escaped_string' => ['escaped_string' => '"bar"'],
'/\"escaped_key\"' => ['"escaped_key"' => 'baz'],
'/unicode' => ['unicode' => "hej då"],
'/float' => ['float' => 3.14],
'/bool' => ['bool' => false],
'/null' => ['null' => null],
'/empty_array' => ['empty_array' => []],
'/empty_object' => ['empty_object' => []],
'/10' => [],
'/foo' => [],
'/' => ['' => 0],
'/a~1b' => ['a/b' => 1],
'/c%d' => ['c%d' => 2],
'/e^f' => ['e^f' => 3],
'/g|h' => ['g|h' => 4],
'/i\\\\j' => ['i\\j' => 5],
'/k\"l' => ['k"l' => 6],
'/ ' => [' ' => 7],
'/m~0n' => ['m~n' => 8],
],
];

View File

@ -0,0 +1,178 @@
<?php
return [
'complex_array' => [
'' => $complexArray = require __DIR__ . '/../parsing/complex_array.php',
'/-' => $complexArray,
'/-/id' => ['id' => '0003'],
'/-/batters' => [
'batters' => [
'batter' => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
],
],
],
'/-/batters/batter' => [
'batter' => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
],
],
'/-/batters/batter/-' => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
'/-/batters/batter/-/id' => ['id' => "1002"],
],
'complex_object' => [
'' => require __DIR__ . '/../parsing/complex_object.php',
'/-' => [],
'/id' => ['id' => '0001'],
'/batters' => [
'batters' => [
'batter' => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
],
],
'/batters/batter' => [
'batter' => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
],
'/batters/batter/-' => [
[
"id" => "1001",
"type" => "Regular",
],
[
"id" => "1002",
"type" => "Chocolate",
],
[
"id" => "1003",
"type" => "Blueberry",
],
[
"id" => "1004",
"type" => "Devil's Food",
],
],
'/batters/batter/-/id' => ['id' => "1004"],
],
'empty_array' => [
'' => [],
'/-' => [],
'/-1' => [],
'/0' => [],
'/foo' => [],
],
'empty_object' => [
'' => [],
'/-' => [],
'/-1' => [],
'/0' => [],
'/foo' => [],
],
'simple_array' => [
'' => $simpleArray = require __DIR__ . '/../parsing/simple_array.php',
'/-' => $simpleArray,
'/-1' => [],
'/0' => [0 => 1],
'/1' => [1 => ''],
'/2' => [2 => 'foo'],
'/3' => [3 => '"bar"'],
'/4' => [4 => 'hej då'],
'/5' => [5 => 3.14],
'/6' => [6 => false],
'/7' => [7 => null],
'/8' => [8 => []],
'/9' => [9 => []],
'/10' => [],
'/foo' => [],
],
'simple_object' => [
'' => require __DIR__ . '/../parsing/simple_object.php',
'/-' => [],
'/-1' => [],
'/int' => ['int' => 1],
'/empty_string' => ['empty_string' => ''],
'/string' => ['string' => 'foo'],
'/escaped_string' => ['escaped_string' => '"bar"'],
'/\"escaped_key\"' => ['"escaped_key"' => 'baz'],
'/unicode' => ['unicode' => "hej då"],
'/float' => ['float' => 3.14],
'/bool' => ['bool' => false],
'/null' => ['null' => null],
'/empty_array' => ['empty_array' => []],
'/empty_object' => ['empty_object' => []],
'/10' => [],
'/foo' => [],
'/' => ['' => 0],
'/a~1b' => ['a/b' => 1],
'/c%d' => ['c%d' => 2],
'/e^f' => ['e^f' => 3],
'/g|h' => ['g|h' => 4],
'/i\\\\j' => ['i\\j' => 5],
'/k\"l' => ['k"l' => 6],
'/ ' => [' ' => 7],
'/m~0n' => ['m~n' => 8],
],
];