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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions .ai-usage-info.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
# AI Usage Info

## Sinn der Library
## Purpose

[hier einfügen]
`phore/json-patch` is a provider-independent PHP value library for atomic RFC 6902
patches and RFC 6901 pointers. It includes optional stable-ID array transport,
operation/path policies, size/depth limits and SHA-256 conflict checks. It has no
AI, HTTP, filesystem persistence or `phore/schema` dependency.

## Beispiele
## Entry points

[hier beispiele in ./examples/ verlinken]
- Namespace: `Phore\JsonPatch` (PSR-4 under `src/`).
- `JsonPatch::fromArray()` creates a native RFC patch with `value` and `from`.
- `JsonPatchApplier::apply()` returns `PatchApplyResult`; read the new document
from `value`. The original stays unchanged, including on failure.
- `JsonPointer::get()` reads a pointer; `escape()` escapes one path segment.
- `StableArrayView::encode()` / `decode()` transform lists to `$values`/`$order`
views. Both map entries and ordering references must be updated in one batch.
- `JsonValue::hash()` hashes a normalized snapshot for `expectedHash` policies.
- No global functions, provider wire formats or PHP struct hydration are defined.

## Globale Funktionen
## Examples

[hier links auf beispiele von speziellen funktionen einfügen]
- [Basic patch and escaping](examples/basic-patch.php)
- [Atomic failure and conflict detection](examples/atomic-failure.php)
- [Stable-ID edits, deletion, insertion and ordering](examples/stable-arrays.php)

Use `stdClass` for JSON objects and PHP lists for JSON arrays. `{}` and `[]` are
different values. Null is a valid explicit `value`. Paths are sequential, not
relative to the original snapshot. The default allowlist is add/remove/replace/test;
move/copy and root mutations require explicit opt-in. Errors provide machine-readable
codes and operation metadata without patch values.

Run `composer test` and `composer examples`. The comprehensive API and package
publication instructions are in [README.md](README.md).
33 changes: 23 additions & 10 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
name: tests

on: [push]
on: [push, pull_request]

jobs:
build:
permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest

container:
image: php:8.5-cli
steps:
- uses: actions/checkout@v1
- name: Download kickstart
run: curl -o ./kickstart.sh 'https://raw.githubusercontent.com/nfra-project/nfra-kickstart/master/dist/kickstart.sh' && chmod +x ./kickstart.sh
- name: UnitTests
run: ./kickstart.sh :test

- uses: actions/checkout@v4
- name: Install Composer
run: |
apt-get update
apt-get install -y --no-install-recommends git unzip
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php composer-setup.php --install-dir=/usr/local/bin --filename=composer
rm composer-setup.php
- name: Validate package
run: composer validate --strict
- name: Install dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Unit tests
run: composer test
- name: Executable examples
run: composer examples
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ Desktop.ini
$RECYCLE.BIN/
.Trashes/


# PHPUnit generated cache
/.phpunit.cache/
177 changes: 167 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,176 @@
# phore-project-template
Template Repository for phore library projects
# Phore JSON Patch

## Git Submodules
Atomic JSON Patch for PHP 8.5+, with RFC 6901 pointers, all six RFC 6902
operations, configurable limits, optimistic conflict checks and an optional
stable-ID representation for editing arrays. Runtime dependencies: PHP and JSON
only. No AI provider, HTTP client or schema library is required.

Beim Klonen direkt mit auschecken:
Extracted from the deterministic patch core of `phore/ai-harness`. Typed object
hydration, schema validation and AI-generated patch envelopes remain in that
package. Public classes now use `Phore\JsonPatch\`.

```bash
git clone --recurse-submodules <repo-url>
## Installation

The initial package is being prepared for publication. After it is available on
Packagist:

```sh
composer require phore/json-patch
```

For development before publication, clone this repository and run
`composer install`, `composer test` and `composer examples`.

## Apply a patch

```php
require 'vendor/autoload.php';

use Phore\JsonPatch\{JsonPatch, JsonPatchApplier};

$original = (object) ['title' => 'Draft', 'tags' => ['php']];
$patch = JsonPatch::fromArray([
['op' => 'test', 'path' => '/title', 'value' => 'Draft'],
['op' => 'replace', 'path' => '/title', 'value' => 'Published'],
['op' => 'add', 'path' => '/tags/-', 'value' => 'json-patch'],
]);
$result = (new JsonPatchApplier())->apply($original, $patch);
$edited = $result->value;
// $edited: {"title":"Published","tags":["php","json-patch"]}
// $original still contains "Draft" and ["php"].
```

Operations run sequentially against a detached candidate. Any error aborts the
batch without changing the input. Operation values are also copied on construction
and access, and `copy` creates independent values. An optional `validator` callback
receives a separate copy of the final candidate once; returning `false` or throwing
rejects it. This does not roll back external side effects in application callbacks.

JSON objects are `stdClass`, arrays are PHP lists. `JsonValue::decode()` preserves
that distinction; use `(object) []` for `{}` and `[]` for `[]`. Associative PHP
arrays normalize to objects. Null is a value, distinct from a missing member.
`test` compares object members without ordering, arrays with ordering and numbers
numerically; strings and booleans are distinct from numbers.

## Pointers and operations

| Operation | Meaning | Required fields |
|---|---|---|
| `add` | Insert into a list or create/overwrite an object member | `op`, `path`, `value` |
| `remove` | Remove an existing value | `op`, `path` |
| `replace` | Replace an existing value | `op`, `path`, `value` |
| `move` | Remove source, then add at destination | `op`, `path`, `from` |
| `copy` | Copy source by value into destination | `op`, `path`, `from` |
| `test` | Reject the batch unless the existing value is equal | `op`, `path`, `value` |

`JsonPointer::get($document, $pointer)` reads a value; `JsonPointer::escape($key)`
escapes a property name. The empty pointer `''` addresses the root, `/` the
empty-string property, `~0` a tilde and `~1` a slash. URI fragments and percent
decoding are not supported. Array indices must be canonical nonnegative decimal
integers without leading zeros. `-` appends for add destinations, including those
of move/copy. Parents must already exist. Array indices always refer to the state
after preceding operations; move resolves the destination after removal.

All six operations are implemented. Defaults deliberately allow only
`add`, `remove`, `replace`, `test`, and forbid root mutations. To enable the full
operation set and root changes:

```php
use Phore\JsonPatch\PatchApplyOptions;

$options = new PatchApplyOptions(
allowedOperations: ['add', 'remove', 'replace', 'move', 'copy', 'test'],
allowRootReplacement: true,
);
$result = (new JsonPatchApplier())->apply($original, $patch, $options);
```

Nachträglich initialisieren oder aktualisieren:
Removing the root sets `documentExists` to `false`; this differs from JSON null.
Only a subsequent root `add` can recreate it. Moving a path into its own descendant
is rejected, including array paths whose indices would shift. `fromArray()` ignores
unrecognized extension members; the typed operation contract rejects a non-null
`from` on add/remove/replace/test and non-null `value` on remove/move/copy.

## Limits and conflict protection

| `PatchApplyOptions` property | Default | Effect |
|---|---|---|
| `atomic` | `true` | Non-atomic application is unsupported. |
| `maxOperations` | `100` | Bounds batch length. |
| `maxPatchBytes` | `65536` | Bounds serialized native patch size. |
| `maxDocumentBytes` | `4194304` | Bounds input and each intermediate document. |
| `maxDepth` | `64` | Bounds documents and pointers. |
| `allowedOperations` | add, remove, replace, test | Explicit operation allowlist. |
| `allowRootReplacement` | `false` | Enables root mutations. |
| `forbiddenPaths` | `[]` | Protects overlapping paths and indirect array shifts. |
| `requireTests` | `'none'` | `'arrays'` guards positional edits; `'all'` guards every remove/replace/move source. |
| `expectedHash` | `null` | Requires the current input hash to match. |
| `dryRun` | `false` | Returns a validated candidate with `dry_run` status. |

A required guard must be the immediately preceding successful `test`. It must
cover the mutation or an identity/value inside the exact array element being
removed/replaced. Guards do not make numeric indices stable. Protected paths block
reads as well as mutations, including ancestors and copy/move sources.

Use `JsonValue::hash($snapshot)` as `expectedHash`. Hashes sort object keys
recursively and preserve list order; this is a local format, not RFC 8785.
A missing document hashes as SHA-256 of `undefined`. Persistence code must compare
and swap the expected version/hash in its own transaction; this library only
checks the supplied in-memory document. Dry run also leaves the input unchanged.

`PatchApplyResult` exposes `value`, `documentExists`, `status`, `oldHash`, `newHash`,
`operationCount`, `changedPaths` and `patch`. Changed paths include explicit
no-op replacements and move sources; they are not a minimal semantic diff.

Exceptions derive from `PatchValidationException`: `PatchTestFailedException`,
`PatchLimitException` and `PatchConflictException`. Operation failures expose
`errorCode`, `operationIndex` (zero-based), `op` and `path`, without embedding
values in messages. Configuration errors throw `InvalidArgumentException`.

## Stable array addressing

```bash
git submodule update --init --recursive
git submodule update --remote --merge
`StableArrayView` encodes lists into ordinary JSON objects with `$order` and
`$values`. Patches remain standard JSON Patch; IDs replace positional addressing
inside this transport view.

```json
{"items":{"$order":["item_a","item_b"],"$values":{"item_a":{"id":"a"},"item_b":{"id":"b"}}}}
```

The keys above illustrate the shape. Actual encoding produces opaque deterministic
keys from scalar `id`, `key` or `uuid` values. Missing/duplicate automatic identities
get request-local ephemeral keys. `new StableArrayView(['/items' => '/id'])`
requires a present, unique scalar identity at that pointer for each item. Explicit
identities may use nested pointers such as `/metadata/uuid`.

1. Encode the snapshot and retain the generated keys.
2. Edit `/items/$values/<key>/name` to change a particular item.
3. Add/remove both the value entry and its `$order` reference in the batch.
4. Replace `$order` to reorder items, then decode the final candidate.

Business-ID changes do not retarget existing transport keys during a batch.
Ephemeral keys have no cross-request meaning. Every nested/new list uses the same
wrapper, except `$order` itself. Decoding rejects duplicate references, missing
values, orphan values, malformed wrappers and unwrapped lists. Original objects
with reserved `$order` or `$values` members cannot use this mode.

Validate the final transport in a callback that calls `$codec->decode($candidate)`
and returns void, as in the executable example. Intermediate steps may temporarily
have unmatched references; validation belongs after the batch.

## Executable examples and tests

- [Basic patch and escaped pointer](examples/basic-patch.php)
- [Atomic failure and stale-hash conflict](examples/atomic-failure.php)
- [Combined stable-array deletion, edit, addition and reorder](examples/stable-arrays.php)
- [Coverage comparison, upstream sources and deliberate differences](docs/test-coverage.md)

```sh
composer validate --strict
composer test
composer examples
```

CI executes the suite and examples on PHP 8.5. The package includes the unchanged
Apache-2.0 JSON Patch test corpus with its attribution and license; see
`test/fixtures/json-patch-tests`. Runtime source retains the MIT package license.
45 changes: 29 additions & 16 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "phore/project",
"name": "phore/json-patch",
"type": "library",
"description": "",
"description": "Atomic RFC 6902 JSON Patch and RFC 6901 JSON Pointer for PHP, with stable-ID array views, limits and conflict protection.",
"license": "MIT",
"authors": [
{
Expand All @@ -11,27 +11,40 @@
],
"autoload": {
"psr-4": {
"Phore\\FileSystem\\": "src/"
},
"files": [
"src/functions.php"
]
"Phore\\JsonPatch\\": "src/"
}
},
"require": {
"php" : ">=8.3",
"ext-yaml": "*",
"php": ">=8.5",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^13.2.2"
},
"suggest": {
},
"config": {
"preferred-install": "source",
"allow-plugins": {
"php-http/discovery": false
}
"preferred-install": "dist",
"sort-packages": true
},
"minimum-stability": "dev"
"keywords": [
"json",
"json-patch",
"json-pointer",
"rfc6902",
"rfc6901",
"atomic",
"stable-id"
],
"homepage": "https://github.com/phore/phore-json-patch",
"support": {
"issues": "https://github.com/phore/phore-json-patch/issues",
"source": "https://github.com/phore/phore-json-patch"
},
"scripts": {
"test": "phpunit -c phpunit.xml.dist",
"examples": [
"@php examples/basic-patch.php",
"@php examples/atomic-failure.php",
"@php examples/stable-arrays.php"
]
}
}
Loading
Loading