Skip to content

Commit 41d4d47

Browse files
committed
Initial release
0 parents  commit 41d4d47

26 files changed

+3810
-0
lines changed

.editorconfig

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
root = true
2+
3+
[*]
4+
charset = utf-8
5+
end_of_line = lf
6+
insert_final_newline = true
7+
indent_style = space
8+
indent_size = 4
9+
trim_trailing_whitespace = true
10+
11+
[*.{yml,yaml,json,md}]
12+
indent_size = 2

.gitattributes

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/.git* export-ignore
2+
/.php-cs-fixer.dist.php export-ignore
3+
/phpstan.dist.neon export-ignore
4+
/phpunit.dist.xml export-ignore
5+
/docs/ export-ignore
6+
/tests/ export-ignore

.github/FUNDING.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
github: [smnandre]

.github/workflows/CI.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ "*" ]
6+
pull_request:
7+
branches: [ "*" ]
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.ref }}
15+
cancel-in-progress: true
16+
17+
jobs:
18+
19+
cs:
20+
uses: phpalto/.github/.github/workflows/CS.yml@main
21+
# with:
22+
# php-version: '8.5'
23+
# composer-validate: true
24+
# php-cs-fixer-args: '--diff --dry-run'
25+
26+
sa:
27+
uses: phpalto/.github/.github/workflows/SA.yml@main
28+
# with:
29+
# php-version: '8.5'
30+
# phpstan-args: 'analyse --no-progress --memory-limit=-1'
31+
32+
tests:
33+
strategy:
34+
fail-fast: false
35+
matrix:
36+
php: ['8.3', '8.4', '8.5']
37+
uses: phpalto/.github/.github/workflows/tests.yml@main
38+
with:
39+
php-version: ${{ matrix.php }}
40+
# phpunit-args: '--colors=never'

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/.phpunit.cache/
2+
/vendor/
3+
/.php-cs-fixer.cache
4+
/composer.lock
5+
/coverage.xml
6+
/phpstan.neon
7+
/phpunit.xml

.php-cs-fixer.dist.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
$licence = <<<'EOF'
4+
This file is part of the ALTO library.
5+
6+
© 2026–present Simon André
7+
8+
For full copyright and license information, please see
9+
the LICENSE file distributed with this source code.
10+
EOF;
11+
12+
$finder = (new PhpCsFixer\Finder())
13+
->in(__DIR__)
14+
;
15+
16+
return (new PhpCsFixer\Config())
17+
->setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect())
18+
->setFinder($finder)
19+
->setRiskyAllowed(true)
20+
->setRules([
21+
'@PER-CS' => true,
22+
'@Symfony' => true,
23+
'declare_strict_types' => true,
24+
'header_comment' => ['header' => $licence],
25+
])
26+
;

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# CHANGELOG
2+
3+
## [1.0.0] - 2026-01-17
4+
5+
* Initial release

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Simon André
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# ALTO \ JSON Patch
2+
3+
A strict, auditable [JSON Patch](https://en.wikipedia.org/wiki/JSON_Patch) implementation for PHP 8.3+. This library handles two concerns with precision:
4+
5+
1. **Apply**: A deterministic **[RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902)** engine that replays patches exactly.
6+
2. **Diff**: A smart diff generator that produces stable, readable patches.
7+
8+
Built for systems where change history matters.
9+
10+
---
11+
12+
&nbsp; [![PHP Version](https://img.shields.io/badge/PHP-8.3+-ffefdf?logoColor=white&labelColor=000)](https://github.com/PhpAlto/json-patch)
13+
&nbsp; [![CI](https://img.shields.io/github/actions/workflow/status/PhpAlto/json-patch/CI.yml?branch=main&label=Tests&logoColor=white&logoSize=auto&labelColor=000&color=ffefdf)](https://github.com/PhpAlto/json-patch/actions)
14+
&nbsp; [![Packagist Version](https://img.shields.io/packagist/v/alto/json-patch?label=Stable&logoColor=white&logoSize=auto&labelColor=000&color=ffefdf)](https://packagist.org/packages/alto/json-patch)
15+
&nbsp; [![PHP Version](https://img.shields.io/badge/PHPUnit-100%25-ffefdf?logoColor=white&labelColor=000)](https://github.com/PhpAlto/json-patch)
16+
&nbsp; [![PHP Version](https://img.shields.io/badge/PHPStan-LVL%2010-ffefdf?logoColor=white&labelColor=000)](https://github.com/PhpAlto/json-patch)
17+
&nbsp; [![License](https://img.shields.io/github/license/PhpAlto/json-patch?label=License&logoColor=white&logoSize=auto&labelColor=000&color=ffefdf)](./LICENSE)
18+
19+
* **Pure PHP**: Tiny surface area, no heavy dependencies.
20+
* **Strict Types**: Built for PHP 8.3+ with strict typing.
21+
* **Deterministic**: Error model designed for auditability.
22+
* **Smart Diffing**: Supports standard list replacement or smart "by-id" list diffing for readable patches.
23+
24+
## Installation
25+
26+
```bash
27+
composer require alto/json-patch
28+
```
29+
30+
## Why Alto JSON Patch?
31+
32+
**For audit logs**: Deterministic apply means you can verify patch integrity. Store the parent hash, the patch, and the
33+
result hash. Replaying the patch will always produce the same result.
34+
35+
**For readable diffs**: Generate clean patches that humans can review. Optional identity-based list diffing produces
36+
granular operations instead of replacing entire arrays.
37+
38+
**For reliability**: Pure PHP with strict types. No magic, no surprises.
39+
40+
## Quick Start
41+
42+
```php
43+
use Alto\JsonPatch\JsonPatch;
44+
45+
$document = [
46+
'user' => ['name' => 'Alice', 'role' => 'editor'],
47+
'status' => 'draft',
48+
];
49+
50+
$patch = [
51+
['op' => 'replace', 'path' => '/user/role', 'value' => 'admin'],
52+
['op' => 'replace', 'path' => '/status', 'value' => 'published'],
53+
];
54+
55+
$result = JsonPatch::apply($document, $patch);
56+
// ['user' => ['name' => 'Alice', 'role' => 'admin'], 'status' => 'published']
57+
```
58+
59+
## Generate Patches
60+
61+
Create patches automatically by diffing two states:
62+
63+
```php
64+
$before = ['version' => 1, 'status' => 'draft'];
65+
$after = ['version' => 2, 'status' => 'published', 'author' => 'Alice'];
66+
67+
$patch = JsonPatch::diff($before, $after);
68+
// [
69+
// ['op' => 'replace', 'path' => '/version', 'value' => 2],
70+
// ['op' => 'replace', 'path' => '/status', 'value' => 'published'],
71+
// ['op' => 'add', 'path' => '/author', 'value' => 'Alice'],
72+
// ]
73+
```
74+
75+
## Smart List Diffing
76+
77+
By default, lists are replaced entirely when they differ. For granular control, use identity-based diffing:
78+
79+
```php
80+
use Alto\JsonPatch\DiffOptions;
81+
82+
$before = [
83+
'items' => [
84+
['id' => 'a', 'qty' => 1],
85+
['id' => 'b', 'qty' => 2],
86+
],
87+
];
88+
89+
$after = [
90+
'items' => [
91+
['id' => 'b', 'qty' => 3], // Modified and reordered
92+
['id' => 'c', 'qty' => 1], // Added
93+
],
94+
];
95+
96+
$options = new DiffOptions(['/items' => 'id']);
97+
$patch = JsonPatch::diff($before, $after, $options);
98+
// Generates move, add, remove, and replace operations for individual items
99+
```
100+
101+
This produces readable patches where reviewers can see exactly which items changed.
102+
103+
## Utility Methods
104+
105+
```php
106+
// Get a value at a JSON pointer path
107+
$name = JsonPatch::get($document, '/user/name');
108+
109+
// Test if a value matches (returns bool)
110+
$isAdmin = JsonPatch::test($document, '/user/role', 'admin');
111+
112+
// Validate patch structure without applying
113+
$errors = JsonPatch::validate($patch);
114+
```
115+
116+
## Audit Trail Example
117+
118+
```php
119+
class ChangeLog
120+
{
121+
public function recordChange(array $before, array $after): void
122+
{
123+
$patch = JsonPatch::diff($before, $after);
124+
125+
$this->store([
126+
'parent_hash' => hash('sha256', json_encode($before)),
127+
'patch' => $patch,
128+
'result_hash' => hash('sha256', json_encode($after)),
129+
'timestamp' => time(),
130+
]);
131+
}
132+
133+
public function verifyIntegrity(string $recordId): bool
134+
{
135+
$record = $this->fetch($recordId);
136+
$parent = $this->reconstructState($record['parent_hash']);
137+
138+
$result = JsonPatch::apply($parent, $record['patch']);
139+
$computedHash = hash('sha256', json_encode($result));
140+
141+
return $computedHash === $record['result_hash'];
142+
}
143+
}
144+
```
145+
146+
## Supported Operations
147+
148+
All RFC 6902 operations:
149+
150+
- `add`: Add a value at a path
151+
- `remove`: Remove a value at a path
152+
- `replace`: Replace a value at a path
153+
- `move`: Move a value from one path to another
154+
- `copy`: Copy a value from one path to another
155+
- `test`: Assert a value matches (useful for conditional patches)
156+
157+
## Error Handling
158+
159+
Operations throw `JsonPatchException` with clear messages:
160+
161+
```php
162+
try {
163+
JsonPatch::apply($doc, $patch);
164+
} catch (JsonPatchException $e) {
165+
// "Operation 0 (replace): path '/missing/path' not found."
166+
// "Operation 1 (add): invalid path '/items/-1'."
167+
}
168+
```
169+
170+
## Advanced Usage
171+
172+
### Float Comparison
173+
`JsonPatch` uses strict equality (`===`) for values. Be aware that `json_decode` may treat numbers differently depending on flags.
174+
For example, `1.0` (float) is not strictly equal to `1` (int). Ensure your input documents use consistent types if strict equality is required.
175+
176+
## Limitations
177+
178+
### `applyJson`: Empty Object vs Array
179+
180+
When using `JsonPatch::applyJson()`, the underlying `json_decode` converts empty JSON objects `{}` into empty PHP arrays
181+
`[]`.
182+
Since PHP does not distinguish between empty associative arrays (objects) and empty indexed arrays (lists), an input of
183+
`{"key": {}}` may result in `{"key": []}` after a round-trip.
184+
If strictly preserving `{}` vs `[]` is critical, consider using `apply()` with pre-decoded structures where you can
185+
control the object mapping (e.g. `json_decode($json, false)` for `stdClass`).
186+
187+
## API Reference
188+
189+
### `JsonPatch`
190+
191+
| Method | Description |
192+
|-------------------------------------------------------------------------|------------------------------------------|
193+
| `apply(array $doc, array $patch): array` | Apply a patch to a document |
194+
| `applyJson(string $docJson, string $patchJson, int $flags = 0): string` | Apply patch to JSON string |
195+
| `diff(array $from, array $to, ?DiffOptions $opts = null): array` | Generate patch from two states |
196+
| `get(array $doc, string $path): mixed` | Get value at JSON pointer path |
197+
| `test(array $doc, string $path, mixed $value): bool` | Test if value matches at path |
198+
| `validate(array $patch): array` | Validate patch structure, returns errors |
199+
200+
### `DiffOptions`
201+
202+
Configure identity-based list diffing:
203+
204+
```php
205+
$options = new DiffOptions([
206+
'/users' => 'id', // Use 'id' field for /users array
207+
'/items' => 'sku', // Use 'sku' field for /items array
208+
]);
209+
```
210+
211+
## License
212+
213+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
214+
````

0 commit comments

Comments
 (0)